Merge: recherche produit + fiche 360° local/réseau

This commit is contained in:
Claude committed 2026-07-27 14:30:42 +00:00
commit ec35985c50
25 files changed
+2670 -441

No files matched your search

+2 -1
View File
@@ -113,7 +113,8 @@ async function main() {
ALTER TABLE "qlik_network_metrics"
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 "qte_by_month" jsonb,
ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb;
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau";
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau";
`);
+546
View File
@@ -0,0 +1,546 @@
"use client";
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,
} 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 type { ProduitFiche } from "@/features/produits/types";
import { OpportunitesFamille } from "./opportunites";
// ─── Formatteurs partagés ────────────────────────────────────────────────────
const fmtEur = (v: number) =>
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", 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);
// ─── Briques de présentation ─────────────────────────────────────────────────
function Card({ title, icon: Icon, children, action }: {
title: string;
icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
children: React.ReactNode;
action?: React.ReactNode;
}) {
return (
<section className="apple-card">
<div className="apple-card-header flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>
<Icon className="w-4 h-4" style={{ color: "var(--accent)" }} />
{title}
</h2>
{action}
</div>
<div className="apple-card-content">{children}</div>
</section>
);
}
function Field({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
return (
<div className="min-w-0">
<div className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
{label}
</div>
<div className={`text-[13px] truncate ${mono ? "font-mono" : ""}`} style={{ color: "var(--text-primary)" }}>
{value ?? "—"}
</div>
</div>
);
}
function Stat({ label, value, hint, color }: { label: string; value: string; hint?: string; color?: string }) {
return (
<div className="rounded-xl p-3" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>
{label}
</div>
<div className="text-[19px] font-black tabular-nums leading-tight mt-0.5" style={{ color: color ?? "var(--text-primary)" }}>
{value}
</div>
{hint && <div className="text-[10px] mt-0.5" style={{ color: "var(--text-muted)" }}>{hint}</div>}
</div>
);
}
// ─── Bouton d'extraction Qlik pour un seul code centrale ─────────────────────
function SyncProduitButton({ codeCentrale }: { codeCentrale: string }) {
const router = useRouter();
const { status, message, start } = useQlikSyncJob({
target: { mode: "produit", codeCentrale },
onSuccess: () => router.refresh(),
});
return (
<div className="flex items-center gap-2">
{message && (
<span
className="text-[10px] leading-tight text-right max-w-[280px]"
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
title={message}
>
{message}
</span>
)}
<button
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"
>
{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"}
</button>
</div>
);
}
// ─── Fiche ───────────────────────────────────────────────────────────────────
export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; backQuery: string }) {
const { detail, fournisseurs, stock, commandesEnCours, gammes, months, reseau } = fiche;
const [magasin, setMagasin] = useState<string>("TOTAL");
const sitesDisponibles = useMemo(() => {
const sites = new Set<string>([...Object.keys(fiche.mensuelParSite), ...stock.map((s) => s.site)]);
return [...sites].filter(Boolean).sort();
}, [fiche.mensuelParSite, stock]);
// Série affichée selon le magasin sélectionné.
const serie = magasin === "TOTAL" ? fiche.mensuelTotal : (fiche.mensuelParSite[magasin] ?? {});
const totauxAffiches = magasin === "TOTAL" ? fiche.totaux : (fiche.totauxParSite[magasin] ?? { qte: 0, ca: 0, marge: 0, tauxMarge: 0 });
const trend = useMemo(() => computeNetworkTrend(reseau?.qteByMonth ?? null), [reseau]);
const comparatif = useMemo(() => computeComparatif(fiche), [fiche]);
const verdict = indiceVerdict(comparatif.indiceQte);
const fournisseurPrincipal = fournisseurs.find((f) => f.principal) ?? fournisseurs[0];
return (
<div className="space-y-5">
{/* En-tête */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<Link
href={backQuery ? `/produits?q=${encodeURIComponent(backQuery)}` : "/produits"}
className="inline-flex items-center gap-1.5 text-[12px] mb-1.5 hover:underline"
style={{ color: "var(--text-muted)" }}
>
<ArrowLeft className="w-3.5 h-3.5" />
Retour aux résultats
</Link>
<h2 className="text-[20px] font-semibold tracking-[-0.3px]" style={{ color: "var(--text-primary)" }}>
{detail.libelle1 || "Sans libellé"}
</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}</>}
</p>
</div>
{fournisseurPrincipal?.codefou && (
<Link
href={`/grid?fournisseur=${encodeURIComponent(fournisseurPrincipal.codefou)}&magasin=TOTAL`}
className="btn-action btn-action-secondary flex items-center gap-1.5"
title="Ouvrir la grille d'arbitrage de ce fournisseur"
>
<LayoutGrid className="w-3.5 h-3.5" />
Voir dans la Grille
</Link>
)}
</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) : "—"} />
</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 */}
<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"
icon={Globe}
action={detail.code_centrale ? <SyncProduitButton codeCentrale={detail.code_centrale} /> : undefined}
>
{!detail.code_centrale ? (
<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>
) : !reseau ? (
<div className="flex items-start gap-3 rounded-xl p-4" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<WifiOff className="w-4 h-4 mt-0.5 shrink-0" style={{ color: "var(--text-muted)" }} />
<div className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
Aucune donnée réseau en cache pour ce produit.
<div className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
Lancez l&apos;extraction ci-dessus : elle ne porte que sur ce code centrale et prend quelques secondes.
</div>
</div>
</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)} />
<Stat
label="Magasins"
value={`${reseau.nbMagasinsReseau}`}
hint={`sur ${NB_MAGASINS_RESEAU} · ${fmtDec((reseau.nbMagasinsReseau / NB_MAGASINS_RESEAU) * 100, 0)} %`}
/>
<Stat label="CA / magasin" value={fmtEur(reseau.caParMagasinReseau)} />
<Stat
label="Marge % réseau"
value={comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"}
/>
<Stat
label="Tendance 12 m"
value={trend.pct != null ? `${trend.pct >= 0 ? "+" : ""}${Math.round(trend.pct * 100)} %` : "—"}
hint={trendLabel(trend.pct)}
color={TREND_COLOR[trend.direction]}
/>
</div>
<p className="text-[11px] mt-3" style={{ color: "var(--text-muted)" }}>
Période {reseau.periode ?? "12 mois"} · extrait le {formatDate(reseau.fetchedAt)}
</p>
{trend.hasData && (
<div className="mt-3">
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
Quantités vendues réseau, mois par mois
</div>
<NetworkLineChart labels={trend.labels} values={trend.values} color={TREND_COLOR[trend.direction]} />
</div>
)}
{/* Détail mensuel complet (CA, marge, magasins) si disponible */}
{reseau.metricsByMonth && Object.keys(reseau.metricsByMonth).length > 0 && (
<div className="mt-4 overflow-x-auto">
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1.5" style={{ color: "var(--text-muted)" }}>
Détail mensuel réseau
</div>
<table className="w-full border-collapse text-[11px]">
<thead>
<tr>
<th className="px-2 py-1.5 text-left font-semibold" style={{ color: "var(--text-muted)" }}>Mesure</th>
{months.map((m) => (
<th key={m} className="px-1 py-1.5 text-center font-semibold whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{formatMonthLabel(m)}
</th>
))}
</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}>
<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];
return (
<td key={m} className="px-1 py-1 text-center tabular-nums" style={{ color: "var(--text-muted)" }}>
{v != null ? row.fmt(v) : "—"}
</td>
);
})}
</tr>
))}
</tbody>
</table>
<p className="text-[10px] mt-1.5" style={{ color: "var(--text-muted)" }}>
Les mois sans CA ni marge proviennent de la mesure « Quantité COMP », qui ne porte que la quantité.
</p>
</div>
)}
</>
)}
</Card>
{/* Comparatif local vs réseau */}
{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
label="Indice de performance"
value={comparatif.indiceQte != null ? `${Math.round(comparatif.indiceQte)} %` : "—"}
hint={verdict.label}
color={verdict.color}
/>
<Stat
label="Nos ventes / magasin"
value={fmtQte(comparatif.qteLocaleParMagasin)}
hint={`${comparatif.nbMagasinsLocaux} magasin${comparatif.nbMagasinsLocaux > 1 ? "s" : ""} · 12 mois`}
/>
<Stat
label="Réseau / magasin"
value={fmtQte(comparatif.qteReseauParMagasin)}
hint={`${comparatif.nbMagasinsReseau} magasins · 12 mois`}
/>
<Stat
label="Présence réseau"
value={comparatif.tauxPresence != null ? `${fmtDec(comparatif.tauxPresence * 100, 0)} %` : "—"}
hint={`${comparatif.nbMagasinsReseau} / ${NB_MAGASINS_RESEAU} magasins`}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<div className="rounded-xl p-3 text-[12px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
CA par magasin · indicatif
</div>
<div className="flex items-center justify-between" style={{ color: "var(--text-secondary)" }}>
<span>Nous : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{fmtEur(comparatif.caLocalParMagasin)}</span></span>
<span>Réseau : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{comparatif.caReseauParMagasin != null ? fmtEur(comparatif.caReseauParMagasin) : "—"}</span></span>
</div>
</div>
<div className="rounded-xl p-3 text-[12px]" style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}>
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
Taux de marge · indicatif
</div>
<div className="flex items-center justify-between" style={{ color: "var(--text-secondary)" }}>
<span>Nous : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{fmtDec(comparatif.tauxMargeLocal)} %</span></span>
<span>Réseau : <span className="font-semibold tabular-nums" style={{ color: "var(--text-primary)" }}>{comparatif.tauxMargeReseau != null ? `${fmtDec(comparatif.tauxMargeReseau)} %` : "—"}</span></span>
</div>
</div>
</div>
<p className="text-[11px] mt-2" style={{ color: "var(--text-muted)" }}>
L&apos;indice compare les <strong>quantités</strong> par magasin, seule base commune fiable.
Notre CA est TTC (issu des mouvements de caisse) ; la base du CA Qlik n&apos;est pas
documentée — montants et marges sont donc donnés à titre indicatif.
</p>
{comparatif.hasMonthly && (
<div className="mt-4">
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
Quantité par magasin, mois par mois
</div>
<DualLineChart
labels={comparatif.monthly.map((m) => ffMonthToQlik(m.mois))}
series={[
{ name: "Nous / magasin", values: comparatif.monthly.map((m) => m.local), color: "#0ea5e9" },
{ name: "Réseau / magasin", values: comparatif.monthly.map((m) => m.reseau), color: "#f59e0b" },
]}
/>
</div>
)}
</Card>
)}
{/* 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}
currentCodein={detail.codein}
familleLabel={detail.libelle3 ?? detail.libelle2 ?? ""}
/>
</Card>
)}
{/* Sparkline discrète en pied de fiche, cohérente avec la Grille */}
{trend.hasData && (
<div className="flex items-center justify-end gap-2 text-[11px]" style={{ color: "var(--text-muted)" }}>
<span>Tendance réseau :</span>
<TrendSparkline trend={trend} />
</div>
)}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import LoadingModal from "@/components/shared/loading-modal";
export default function ProduitsLoading() {
return <LoadingModal message="Chargement des données" subMessage="Recherche du produit..." />;
}
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Loader2, TrendingUp } from "lucide-react";
import type { PgOpportuniteRow } from "@/lib/pg-ff-client";
const fmtQte = (v: number) => new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
const fmtDec = (v: number) => new Intl.NumberFormat("fr-FR", { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(v);
/**
* Produits de la même famille que le produit affiché, classés par écart de
* performance réseau vs locale (quantité **par magasin**, pour neutraliser
* l'écart d'échelle entre ~270 magasins et nos 2 sites).
*
* Chargement à la demande : la requête agrège 12 mois de mouvements pour toute
* la famille, elle ne doit pas ralentir l'ouverture de la fiche.
*/
export function OpportunitesFamille({
nomNoId,
currentCodein,
familleLabel,
}: {
nomNoId: number;
currentCodein: string;
familleLabel: string;
}) {
const [rows, setRows] = useState<PgOpportuniteRow[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function load() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/produits/opportunites?nomNoId=${nomNoId}`, { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
setRows((data.rows ?? []) as PgOpportuniteRow[]);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}
if (rows === null) {
return (
<div className="space-y-2">
<p className="text-[13px]" style={{ color: "var(--text-secondary)" }}>
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 ?
</p>
<button onClick={load} disabled={loading} className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <TrendingUp className="w-3.5 h-3.5" />}
{loading ? "Analyse…" : "Analyser la famille"}
</button>
{error && <p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>}
</div>
);
}
// On retire le produit courant : il est déjà détaillé au-dessus.
const others = rows.filter((r) => r.codein !== currentCodein);
if (others.length === 0) {
return (
<p className="text-[13px]" style={{ color: "var(--text-muted)" }}>
Aucune autre référence de cette famille n&apos;a de données réseau en cache.
Synchronisez d&apos;autres fournisseurs pour enrichir la comparaison.
</p>
);
}
return (
<div className="space-y-2">
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--border)" }}>
<table className="w-full border-collapse text-[12px]">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Produit</th>
<th className="px-3 py-2 text-left text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Fournisseur</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Réseau / mag.</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Nous / mag.</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Écart</th>
<th className="px-3 py-2 text-right text-[10px] font-semibold uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Magasins</th>
</tr>
</thead>
<tbody>
{others.map((r) => {
const opportunite = r.ecart_par_magasin > 0;
return (
<tr key={r.codein} className="transition-colors hover:bg-[var(--bg-elevated)]" style={{ borderTop: "1px solid var(--border)" }}>
<td className="px-3 py-2">
<Link
href={`/produits?codein=${encodeURIComponent(r.codein)}`}
className="font-medium hover:underline"
style={{ color: "var(--accent)" }}
>
{r.libelle1 || r.codein}
</Link>
</td>
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{r.fournisseur}</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtDec(r.qte_reseau_par_magasin)}
</td>
<td className="px-3 py-2 text-right tabular-nums" style={{ color: "var(--text-secondary)" }}>
{fmtDec(r.qte_locale_par_magasin)}
</td>
<td
className="px-3 py-2 text-right tabular-nums font-semibold"
style={{ color: opportunite ? "#f59e0b" : "#22c55e" }}
title={opportunite
? "Le réseau vend plus que nous sur ce produit"
: "Nous vendons autant ou plus que le réseau"}
>
{r.ecart_par_magasin > 0 ? "+" : ""}{fmtDec(r.ecart_par_magasin)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-[11px]" style={{ color: "var(--text-muted)" }}>
{fmtQte(r.nb_magasins_reseau)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
É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 <strong>de notre catalogue</strong> 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.
</p>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { pgSearchProduits } from "@/lib/pg-ff-client";
import { getProduitFiche } from "@/features/produits/api/get-produit-fiche";
import { ProduitSearchBar } from "./search-bar";
import { ProduitResults } from "./results";
import { ProduitFicheView } from "./fiche";
export const dynamic = "force-dynamic";
/**
* Recherche produit : par libellé ou par code centrale.
*
* - `?q=…` → liste de résultats
* - `?codein=…` → fiche complète (URL partageable)
*/
export default async function ProduitsPage(props: {
searchParams: Promise<{ q?: string; codein?: string }>;
}) {
const searchParams = await props.searchParams;
const q = (searchParams.q ?? "").trim();
const codein = (searchParams.codein ?? "").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) : [];
return (
<div className="w-full max-w-screen-2xl mx-auto space-y-6">
<header className="space-y-1">
<h1 className="text-2xl font-semibold tracking-[-0.4px]" style={{ color: "var(--text-primary)" }}>
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.
</p>
</header>
{/* key : remonte le champ quand l'URL change (retour arrière, lien partagé) */}
<ProduitSearchBar key={q} initialQuery={q} />
{codein && !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>.
</div>
)}
{fiche && <ProduitFicheView fiche={fiche} backQuery={q} />}
{!codein && (
<ProduitResults rows={results} query={q} />
)}
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import Link from "next/link";
import { PackageSearch, Wifi, WifiOff } from "lucide-react";
import type { PgProduitSearchRow } from "@/lib/pg-ff-client";
function fmtQte(v: number): string {
return new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 0 }).format(v);
}
/** Liste des résultats de recherche. */
export function ProduitResults({ rows, query }: { rows: PgProduitSearchRow[]; query: string }) {
if (!query) {
return (
<div
className="rounded-xl p-10 text-center"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)" }}
>
<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
</p>
<p className="text-[12px] mt-1" style={{ color: "var(--text-muted)" }}>
Saisissez un libellé (ex. « poêle 28 ») ou un code centrale (ex. « 10000167303 »).
</p>
</div>
);
}
if (rows.length === 0) {
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)" }}
>
Aucun produit ne correspond à « {query} ».
</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>
<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">
<Link
href={`/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}`}
className="font-medium hover:underline"
style={{ color: "var(--accent)" }}
>
{r.libelle1 || "—"}
</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>
</div>
);
}
function Th({ children, align = "left" }: { children: React.ReactNode; align?: "left" | "right" }) {
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`}
style={{ color: "var(--text-muted)" }}
>
{children}
</th>
);
}
@@ -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 (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-xl">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
style={{ color: "var(--text-muted)" }}
/>
<input
type="text"
value={term}
onChange={(e) => setTerm(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
placeholder="Libellé produit ou code centrale…"
aria-label="Rechercher un produit par libellé ou code centrale"
className="apple-input h-10 text-[14px]"
style={{ paddingLeft: "38px", paddingRight: term ? "36px" : undefined }}
/>
{term && (
<button
onClick={clear}
aria-label="Effacer la recherche"
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 transition-colors hover:bg-[var(--bg-elevated)]"
style={{ color: "var(--text-muted)" }}
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<button
onClick={submit}
disabled={term.trim().length < 3}
className="btn-action btn-action-primary disabled:opacity-50"
>
Rechercher
</button>
</div>
{tooShort && (
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Saisissez au moins 3 caractères.
</p>
)}
</div>
);
}
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client";
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
export const dynamic = "force-dynamic";
/**
* GET /api/produits/opportunites?nomNoId=123
*
* Produits de la même nomenclature, classés par écart entre performance réseau
* et performance locale (quantités par magasin). Chargé à la demande depuis la
* fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille,
* on ne veut pas la payer au chargement de la page.
*
* 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 raw = req.nextUrl.searchParams.get("nomNoId");
const nomNoId = Number(raw);
if (!raw || !Number.isInteger(nomNoId) || nomNoId <= 0) {
return NextResponse.json({ error: "Param 'nomNoId' invalide" }, { status: 400 });
}
try {
const { dateDebut, dateFin } = buildLast12MonthsRange();
const rows = await pgGetOpportunitesFamille(nomNoId, dateDebut, dateFin);
return NextResponse.json({ success: true, rows });
} catch (e) {
console.error("[api/produits/opportunites]", (e as Error).message?.slice(0, 250));
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
+163 -9
View File
@@ -26,7 +26,10 @@ type QlikSyncStatus = "idle" | "running" | "success" | "error";
interface QlikSyncJob {
jobId: string;
/** Code fournisseur (mode fournisseur) — chaîne vide en mode produit. */
fournisseur: string;
/** Code centrale (mode produit) — absent en mode fournisseur. */
codeCentrale?: string;
status: QlikSyncStatus;
requested: number;
fetched: number;
@@ -48,6 +51,33 @@ interface QlikSyncJob {
*/
const jobs = new Map<string, QlikSyncJob>();
/**
* Clé de job. Le mode fournisseur garde sa clé historique (le code fournisseur
* brut) ; le mode produit est préfixé pour ne jamais entrer en collision.
*/
function jobKeyForProduct(codeCentrale: string): string {
return `cc:${codeCentrale.toUpperCase()}`;
}
/**
* Nombre maximum d'extractions produit simultanées.
*
* Le mode produit est ouvert à tous les utilisateurs connectés (contrairement au
* mode fournisseur, réservé aux admins). Une extraction sur 1 seul code est très
* légère, mais le serveur Qlik est sujet aux saturations mémoire : on plafonne
* la concurrence plutôt que de lui laisser prendre N requêtes en parallèle.
*/
const MAX_CONCURRENT_PRODUCT_JOBS = 3;
/** Compte les extractions produit actuellement en cours. */
function runningProductJobs(): number {
let n = 0;
for (const [key, job] of jobs) {
if (key.startsWith("cc:") && job.status === "running") n++;
}
return n;
}
/** Construit un jobId court et lisible. */
function newJobId(): string {
return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -58,6 +88,7 @@ function publicJob(job: QlikSyncJob) {
return {
jobId: job.jobId,
fournisseur: job.fournisseur,
codeCentrale: job.codeCentrale,
status: job.status,
requested: job.requested,
fetched: job.fetched,
@@ -72,11 +103,12 @@ function publicJob(job: QlikSyncJob) {
};
}
/** État "idle" sérialisé (pas de job connu pour ce fournisseur). */
function idleJob(fournisseur: string) {
/** État "idle" sérialisé (pas de job connu pour cette cible). */
function idleJob(fournisseur: string, codeCentrale?: string) {
return {
jobId: null,
fournisseur,
codeCentrale,
status: "idle" as const,
requested: 0,
fetched: 0,
@@ -105,6 +137,20 @@ async function requireAdmin(): Promise<NextResponse | null> {
return null;
}
/**
* Vérifie qu'une session existe (sans exiger le rôle admin).
*
* Utilisé par le mode produit : le middleware Next ne couvre PAS les routes
* `/api/*`, le contrôle doit donc être fait ici explicitement.
*/
async function requireSession(): Promise<NextResponse | null> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return null;
}
/**
* Regex de validation d'un code centrale "raisonnable".
*
@@ -209,11 +255,19 @@ async function runJob(job: QlikSyncJob): Promise<void> {
job.status = "success";
job.finishedAt = new Date().toISOString();
} catch (e) {
applyJobError(job, e);
}
}
/**
* Inscrit une erreur dans le job, en traduisant la saturation mémoire du
* serveur Qlik en message actionnable (ce n'est PAS un bug CollectFlow :
* l'app Qlik ne se charge même pas).
*/
function applyJobError(job: QlikSyncJob, e: unknown): void {
const msg = e instanceof Error ? e.message : String(e);
console.error(`[api/qlik/sync] job=${job.jobId}`, msg);
job.status = "error";
// Message explicite quand le serveur Qlik est saturé (OOM à l'ouverture de
// l'app) : ce n'est PAS un bug CollectFlow, l'app Qlik ne se charge même pas.
const lower = msg.toLowerCase();
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes("file corrupted") || lower.includes('"code":6') || lower.includes('"code":3002')) {
job.error = "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).";
@@ -221,22 +275,75 @@ async function runJob(job: QlikSyncJob): Promise<void> {
job.error = msg;
}
job.finishedAt = new Date().toISOString();
}
/**
* Extraction Qlik pour UN seul code centrale (fiche produit).
*
* Beaucoup plus légère que la sync fournisseur : un seul code au lieu de
* plusieurs centaines, sur la même fenêtre 12 mois glissants. Réutilise
* exactement le même extracteur, donc le même détail mensuel.
*/
async function runProductJob(job: QlikSyncJob): Promise<void> {
try {
const code = job.codeCentrale!;
job.requested = 1;
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
job.periode = dateFilter.label;
job.dateDebut = dateFilter.dateDebut;
job.dateFin = dateFilter.dateFin;
console.log(
`[api/qlik/sync] job=${job.jobId} → extraction Qlik produit (1 code) — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
);
const metrics = await fetchNetworkMetricsPlaywright([code], undefined, dateFilter);
job.fetched = metrics.size;
if (metrics.size === 0) {
job.status = "success";
job.message = "Ce code centrale est inconnu de Qlik (aucune vente réseau sur la période)";
job.finishedAt = new Date().toISOString();
return;
}
const count = await upsertNetworkMetrics([...metrics.values()]);
job.upserted = count;
job.status = "success";
job.message = "Données réseau récupérées";
job.finishedAt = new Date().toISOString();
console.log(`[api/qlik/sync] job=${job.jobId} produit ${code} — ${count} ligne(s) upsert`);
} catch (e) {
applyJobError(job, e);
}
}
/**
* GET /api/qlik/sync?fournisseur=XXX
* Retourne l'état courant du job pour ce fournisseur. Si aucun job n'est
* GET /api/qlik/sync?fournisseur=XXX ou ?codeCentrale=YYY
* Retourne l'état courant du job pour cette cible. Si aucun job n'est
* connu, renvoie un état "idle" (avec un message explicite). Permet au
* frontend de reprendre l'état après un reload et de poller en arrière-plan.
*/
export async function GET(req: NextRequest) {
const codeCentrale = req.nextUrl.searchParams.get("codeCentrale");
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
// Mode produit : ouvert à tout utilisateur connecté.
if (codeCentrale) {
const denied = await requireSession();
if (denied) return denied;
const job = jobs.get(jobKeyForProduct(codeCentrale));
if (!job) {
return NextResponse.json({ success: true, ...idleJob("", codeCentrale) });
}
return NextResponse.json({ success: true, ...publicJob(job) });
}
// Mode fournisseur : admin uniquement (inchangé).
const denied = await requireAdmin();
if (denied) return denied;
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
if (!fournisseur) {
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
}
const job = jobs.get(fournisseur);
if (!job) {
@@ -252,12 +359,59 @@ export async function GET(req: NextRequest) {
* ensuite poller GET pour suivre l'avancement et être notifié de la fin.
*/
export async function POST(req: NextRequest) {
const codeCentraleParam = req.nextUrl.searchParams.get("codeCentrale");
// ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ───
if (codeCentraleParam) {
const denied = await requireSession();
if (denied) return denied;
// Même validation que la sync fournisseur (trim, rejet des vides / "-" /
// codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik.
const { accepted } = filterCentralCodes([codeCentraleParam]);
if (accepted.length === 0) {
return NextResponse.json({ error: "Code centrale invalide" }, { status: 400 });
}
const code = accepted[0];
const key = jobKeyForProduct(code);
const existingProduct = jobs.get(key);
if (existingProduct && existingProduct.status === "running") {
return NextResponse.json({ success: true, ...publicJob(existingProduct) });
}
if (runningProductJobs() >= MAX_CONCURRENT_PRODUCT_JOBS) {
return NextResponse.json(
{ error: "Trop d'extractions Qlik en cours. Réessayez dans quelques instants." },
{ status: 429 },
);
}
const productJob: QlikSyncJob = {
jobId: newJobId(),
fournisseur: "",
codeCentrale: code,
status: "running",
requested: 0,
fetched: 0,
upserted: 0,
startedAt: new Date().toISOString(),
};
jobs.set(key, productJob);
console.log(`[api/qlik/sync] job=${productJob.jobId} démarré pour codeCentrale=${code}`);
void runProductJob(productJob).catch((e) => applyJobError(productJob, e));
return NextResponse.json({ success: true, ...publicJob(productJob) });
}
// ─── Mode fournisseur : admin uniquement (inchangé) ──────────────────────
const denied = await requireAdmin();
if (denied) return denied;
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
if (!fournisseur) {
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
}
const existing = jobs.get(fournisseur);
+2 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, MessageSquareCode } from "lucide-react";
import { LayoutGrid, Camera, FileDown, Settings, Package, PackageSearch, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, MessageSquareCode } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
@@ -11,6 +11,7 @@ import { useSession, signOut } from "next-auth/react";
const NAV_ITEMS = [
{ icon: BarChart2, label: "Dashboard", href: "/dashboard" },
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
{ icon: PackageSearch, label: "Produits", href: "/produits" },
{ icon: TrendingUp, label: "Analytics", href: "/analytics" },
{ icon: Trophy, label: "Hit Parade", href: "/hit-parade" },
{ icon: Megaphone, label: "Publicités", href: "/publicites" },
+6
View File
@@ -125,6 +125,12 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
periode: varchar("periode", { length: 20 }),
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (12 derniers mois). */
qteByMonth: jsonb("qte_by_month"),
/**
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
* Superset de `qteByMonth`, qui reste maintenu tel quel pour la colonne
* « Tendance / Réseau » de la Grille.
*/
metricsByMonth: jsonb("metrics_by_month"),
/** Dernière synchro depuis Qlik */
fetchedAt: timestamp("fetched_at").defaultNow(),
});
+1 -2
View File
@@ -15,8 +15,7 @@ import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
const NB_MAGASINS_RESEAU = 270;
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
interface GetProductRowsInput {
codeFournisseur: string;
+15 -190
View File
@@ -13,7 +13,7 @@ import {
RowSelectionState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus } from "lucide-react";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle } from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -26,64 +26,19 @@ import { GammeSelect } from "@/features/grid/components/gamme-select";
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
import type { ProductRow, GammeCode } from "@/types/grid";
import { cn } from "@/lib/utils";
function getLast12Months(): string[] {
const months: string[] = [];
const now = new Date();
// On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier)
// pour exclure le mois en cours (i=0)
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;
}
// La constante est supprimée d'ici pour éviter le mismatch d'hydratation (new Date() au runtime module)
function formatMonthLabel(key: string): string {
const m = parseInt(key.slice(4, 6), 10);
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
return `${names[m - 1]} ${key.slice(2, 4)}`;
}
function formatDate(iso?: string): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
}
/**
* Returns premium styling and color for stores based on the store name's hash.
* This guarantees a consistent color for a given store, regardless of its position in the list.
*/
const SITE_LABELS: Record<string, { label: string; nom: string }> = {
"292": { label: "F", nom: "Frouard (Nancy)" },
"579": { label: "H", nom: "Houdemont" },
};
function getStoreConfig(name: string) {
const known = SITE_LABELS[name];
const words = (known?.nom ?? name).trim().split(/\s+/);
let label = known?.label ?? words[0].charAt(0).toUpperCase();
if (!known && words.length > 1) {
label += words[1].charAt(0).toUpperCase();
}
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const colorInt = Math.abs(hash) % 5;
switch (colorInt) {
case 0: return { bg: "rgba(99, 102, 241, 0.1)", text: "#818cf8", border: "rgba(99, 102, 241, 0.2)", label }; // Indigo
case 1: return { bg: "rgba(245, 158, 11, 0.1)", text: "#fbbf24", border: "rgba(245, 158, 11, 0.2)", label }; // Amber
case 2: return { bg: "rgba(16, 185, 129, 0.1)", text: "#10b981", border: "rgba(16, 185, 129, 0.2)", label }; // Emerald
case 3: return { bg: "rgba(236, 72, 153, 0.1)", text: "#ec4899", border: "rgba(236, 72, 153, 0.2)", label }; // Pink
case 4: return { bg: "rgba(14, 165, 233, 0.1)", text: "#0ea5e9", border: "rgba(14, 165, 233, 0.2)", label }; // Sky
default: return { bg: "var(--bg-elevated)", text: "var(--text-muted)", border: "var(--border)", label };
}
}
import {
getLast12Months,
formatMonthLabel,
formatDate,
SITE_LABELS,
getStoreConfig,
} from "@/features/grid/lib/months";
import {
computeNetworkTrend,
trendLabel,
TREND_COLOR,
} from "@/features/grid/lib/network-trend";
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
interface HeatmapGridProps {
onSelectionChange?: (codeins: string[]) => void;
@@ -238,135 +193,6 @@ interface CellDetailData {
receptions: number | null;
}
// ─── Tendance réseau (à partir de qteReseauByMonth) ───────────────────────────
type NetworkTrend = {
values: number[]; // quantités mensuelles, ordre chronologique
labels: string[]; // "YYYY-MM"
direction: "up" | "down" | "flat";
pct: number | null; // variation modélisée (régression linéaire) sur 12 mois
hasData: boolean;
};
/**
* Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois.
* Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la
* variation modélisée sur la période (pente × durée) rapportée à la moyenne.
* Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
*/
function computeNetworkTrend(qteByMonth?: Record<string, number> | null): NetworkTrend {
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
if (!qteByMonth) return empty;
let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
if (labels.length === 0) return empty;
labels = labels.slice(-12); // 12 derniers mois
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
const n = values.length;
let pct: number | null = null;
let direction: NetworkTrend["direction"] = "flat";
if (n >= 3) {
const mx = (n - 1) / 2;
const my = values.reduce((s, v) => s + v, 0) / n;
let num = 0, den = 0;
for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); }
const slope = den ? num / den : 0;
if (my > 0) {
pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne
direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat";
} else if (slope > 0) {
direction = "up";
}
} else if (n === 2 && values[0] > 0) {
pct = (values[1] - values[0]) / values[0];
direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat";
}
return { values, labels, direction, pct, hasData: true };
}
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */
const TREND_STRONG = 0.25;
const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
up: "#22c55e",
down: "#ef4444",
flat: "#94a3b8",
};
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
function TrendSparkline({ trend }: { trend: NetworkTrend }) {
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
const { values, direction, pct } = trend;
const color = TREND_COLOR[direction];
const W = 46, H = 18, n = values.length;
const max = Math.max(...values, 1), min = Math.min(...values, 0);
const range = max - min || 1;
const pts = values.map((v, i) => {
const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2;
const y = H - 1 - ((v - min) / range) * (H - 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
return (
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null ? `Tendance ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}>
<div className="flex items-center gap-1">
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0" aria-hidden>
{n > 1 && <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />}
{n === 1 && <circle cx={W / 2} cy={H / 2} r={2} fill={color} />}
</svg>
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
</div>
{pct != null && (
<span className="text-[9px] font-bold tabular-nums leading-none" style={{ color }}>
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
</span>
)}
</div>
);
}
/** "2026-01" → "01/26" pour un axe compact. */
function fmtMonthShort(lab: string): string {
const [y, m] = lab.split("-");
return `${m ?? lab}/${(y ?? "").slice(2)}`;
}
/** Courbe unique des ventes réseau mensuelles (SVG). */
function NetworkLineChart({ labels, values, color }: { labels: string[]; values: number[]; color: string }) {
const W = 460, H = 200;
const padL = 10, padR = 10, padT = 22, padB = 30;
const plotW = W - padL - padR, plotH = H - padT - padB;
const n = values.length;
const maxV = Math.max(...values, 1);
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
const areaPts = `${padL},${padT + plotH} ${linePts} ${(padL + plotW).toFixed(1)},${(padT + plotH).toFixed(1)}`;
return (
<div className="mt-3 w-full overflow-x-auto">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 380 }} role="img" aria-label="Ventes réseau mensuelles">
{/* ligne de base */}
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
{/* aire douce sous la courbe */}
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
{/* courbe */}
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
{values.map((v, i) => (
<g key={labels[i]}>
<circle cx={x(i)} cy={y(v)} r={2.6} fill={color} />
{/* quantité au-dessus du point */}
<text x={x(i)} y={y(v) - 6} textAnchor="middle" fontSize={9} fontWeight={700} fill="var(--text-primary)">
{Math.round(v).toLocaleString("fr-FR")}
</text>
{/* mois sous l'axe */}
<text x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
{fmtMonthShort(labels[i])}
</text>
</g>
))}
</svg>
</div>
);
}
/** Modal : quantités vendues réseau sur les 12 derniers mois, en une seule courbe. */
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
const trend = computeNetworkTrend(row.qteReseauByMonth);
@@ -383,8 +209,7 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>
{row.nbMagasinsReseau != null && <> · <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{row.nbMagasinsReseau} magasins</span></>}
{pct != null && (
<> · <span className="font-bold" style={{ color }}>
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% ·{" "}
{pct > TREND_STRONG ? "Forte hausse" : pct > 0.08 ? "Hausse" : pct < -TREND_STRONG ? "Forte baisse" : pct < -0.08 ? "Baisse" : "Stable"}
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)}
</span></>
)}
</p>
@@ -0,0 +1,136 @@
"use client";
/**
* CollectFlow — Graphiques réseau (SVG fait main, aucune librairie de charts).
*
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la fiche
* produit (`/produits`). Aucun changement de comportement.
*/
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { fmtMonthShort } from "@/features/grid/lib/months";
import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend";
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
const { values, direction, pct } = trend;
const color = TREND_COLOR[direction];
const W = 46, H = 18, n = values.length;
const max = Math.max(...values, 1), min = Math.min(...values, 0);
const range = max - min || 1;
const pts = values.map((v, i) => {
const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2;
const y = H - 1 - ((v - min) / range) * (H - 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" ");
const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus;
return (
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null ? `Tendance ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}>
<div className="flex items-center gap-1">
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0" aria-hidden>
{n > 1 && <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />}
{n === 1 && <circle cx={W / 2} cy={H / 2} r={2} fill={color} />}
</svg>
<Arrow className="w-3.5 h-3.5 shrink-0" style={{ color }} strokeWidth={2.5} />
</div>
{pct != null && (
<span className="text-[9px] font-bold tabular-nums leading-none" style={{ color }}>
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
</span>
)}
</div>
);
}
/** Courbe unique des ventes réseau mensuelles (SVG). */
export function NetworkLineChart({ labels, values, color }: { labels: string[]; values: number[]; color: string }) {
const W = 460, H = 200;
const padL = 10, padR = 10, padT = 22, padB = 30;
const plotW = W - padL - padR, plotH = H - padT - padB;
const n = values.length;
const maxV = Math.max(...values, 1);
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
const areaPts = `${padL},${padT + plotH} ${linePts} ${(padL + plotW).toFixed(1)},${(padT + plotH).toFixed(1)}`;
return (
<div className="mt-3 w-full overflow-x-auto">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 380 }} role="img" aria-label="Ventes réseau mensuelles">
{/* ligne de base */}
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
{/* aire douce sous la courbe */}
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
{/* courbe */}
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
{values.map((v, i) => (
<g key={labels[i]}>
<circle cx={x(i)} cy={y(v)} r={2.6} fill={color} />
{/* quantité au-dessus du point */}
<text x={x(i)} y={y(v) - 6} textAnchor="middle" fontSize={9} fontWeight={700} fill="var(--text-primary)">
{Math.round(v).toLocaleString("fr-FR")}
</text>
{/* mois sous l'axe */}
<text x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
{fmtMonthShort(labels[i])}
</text>
</g>
))}
</svg>
</div>
);
}
/**
* Deux courbes superposées sur le même axe (comparatif local vs réseau).
* Les valeurs sont normalisées ensemble pour rester comparables visuellement.
*/
export function DualLineChart({
labels,
series,
}: {
labels: string[];
series: Array<{ name: string; values: number[]; color: string }>;
}) {
const W = 620, H = 220;
const padL = 10, padR = 10, padT = 26, padB = 30;
const plotW = W - padL - padR, plotH = H - padT - padB;
const n = labels.length;
const maxV = Math.max(1, ...series.flatMap((s) => s.values));
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
return (
<div className="w-full overflow-x-auto">
<div className="flex items-center gap-4 mb-1">
{series.map((s) => (
<span key={s.name} className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
<span className="inline-block w-3 h-[3px] rounded-full" style={{ background: s.color }} />
{s.name}
</span>
))}
</div>
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 460 }} role="img" aria-label="Comparatif mensuel local vs réseau">
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
{series.map((s) => {
const pts = s.values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
return (
<g key={s.name}>
{n > 1 && (
<polyline points={pts} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
)}
{s.values.map((v, i) => (
<circle key={`${s.name}-${labels[i]}`} cx={x(i)} cy={y(v)} r={2.4} fill={s.color} />
))}
</g>
);
})}
{labels.map((lab, i) => (
<text key={lab} x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
{fmtMonthShort(lab)}
</text>
))}
</svg>
</div>
);
}
+14 -222
View File
@@ -1,8 +1,9 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
interface SyncQlikButtonProps {
/** Code fournisseur affiché (pour sync ciblée) */
@@ -11,24 +12,8 @@ interface SyncQlikButtonProps {
lastUpdate?: string | null;
}
/** État serveur renvoyé par GET/POST /api/qlik/sync */
interface SyncJobState {
jobId: string | null;
fournisseur: string;
status: "idle" | "running" | "success" | "error";
requested: number;
fetched: number;
upserted: number;
periode?: string;
dateDebut?: string;
dateFin?: string;
startedAt?: string;
finishedAt?: string;
error?: string;
message?: string;
}
function formatDate(iso?: string | null): string {
/** Date + heure de dernière synchro, ou "jamais". */
function formatLastUpdate(iso?: string | null): string {
if (!iso) return "jamais";
const d = new Date(iso);
if (isNaN(d.getTime())) return "jamais";
@@ -39,55 +24,15 @@ function formatDate(iso?: string | null): string {
* Bouton admin : synchronise depuis Qlik les métriques réseau du fournisseur affiché
* (POST /api/qlik/sync?fournisseur=…) puis recharge la grille. Affiche la dernière maj.
*
* UX : le POST démarre un job serveur en arrière-plan et revient immédiatement.
* Le composant poll ensuite GET pour suivre l'avancement. Au mount, on appelle
* GET pour reprendre l'état d'un éventuel job en cours (par ex. après reload de
* la page) et continuer à afficher le spinner correctement.
* Toute la mécanique de job (POST puis polling GET, reprise après reload) vit
* dans `useQlikSyncJob`, partagée avec la fiche produit.
*/
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
const [status, setStatus] = useState<"idle" | "running" | "success" | "error">("idle");
const [message, setMessage] = useState<string>("");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */
const trackingRef = useRef<boolean>(false);
const clearPollTimer = useCallback(() => {
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
/**
* Met à jour le label visible à droite du bouton selon l'état courant.
*/
const applyJobToUi = useCallback((j: SyncJobState | null) => {
if (!j) return;
if (j.status === "running") {
setStatus("running");
// Texte informatif pendant l'extraction
const requestedTxt = j.requested > 0 ? ` (${j.requested} codes)` : "";
setMessage(`Extraction Qlik en cours${requestedTxt}…`);
} else if (j.status === "success") {
setStatus("success");
if (j.message) setMessage(j.message);
else setMessage(`${j.upserted} produits réseau synchronisés`);
} else if (j.status === "error") {
setStatus("error");
setMessage(j.error || "Erreur sync Qlik");
} else {
setStatus("idle");
}
}, []);
/**
* Force le refresh de la grille (avec cache-buster) et de la barre de résumé.
* C'est l'équivalent de ce que faisait l'ancien code après un POST réussi.
*/
/** Force le refresh de la grille (avec cache-buster) et de la barre de résumé. */
const refreshGrid = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.set("_refresh", String(Date.now()));
@@ -95,163 +40,10 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
router.refresh();
}, [pathname, router, searchParams]);
/**
* Polling GET pendant qu'un job tourne. S'arrête tout seul quand le job
* passe en success/error, ou après un timeout de sécurité.
*/
const pollOnce = useCallback(async (): Promise<boolean> => {
if (!codeFournisseur) return false;
if (!trackingRef.current) return false;
try {
const res = await fetch(
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
{ method: "GET", cache: "no-store" },
);
if (!res.ok) {
// En cas d'erreur HTTP transitoire on retente au tour suivant.
return true;
}
const data: SyncJobState = await res.json();
applyJobToUi(data);
if (data.status === "running") {
// Continue à poller
return true;
}
if (data.status === "success" || data.status === "error") {
// Terminé : on rafraîchit la grille, on coupe le polling.
trackingRef.current = false;
if (data.status === "success") {
refreshGrid();
}
// Petit délai pour laisser l'utilisateur lire le message vert/rouge
setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
}, 4000);
return false;
}
// idle : rien à faire
trackingRef.current = false;
return false;
} catch {
// Erreur réseau transitoire, on laisse le polling réessayer.
return true;
}
}, [applyJobToUi, codeFournisseur, refreshGrid]);
/**
* Boucle de polling : s'auto-réarme tant que pollOnce indique qu'il faut
* continuer. Délai un peu plus long que d'habitude (3 s) pour ne pas
* marteler le serveur pendant une extraction qui dure plusieurs minutes.
*/
const startPolling = useCallback(() => {
clearPollTimer();
const tick = async () => {
const keepGoing = await pollOnce();
if (keepGoing && trackingRef.current) {
pollTimerRef.current = setTimeout(tick, 3000);
} else {
pollTimerRef.current = null;
}
};
// Premier appel quasi immédiat
pollTimerRef.current = setTimeout(tick, 200);
}, [clearPollTimer, pollOnce]);
/**
* Au mount : si on a un fournisseur, on interroge l'API pour savoir si
* un job tourne déjà (ex. reload de la page pendant l'extraction). Si
* oui, on reprend l'état et on relance le polling.
*/
useEffect(() => {
if (!codeFournisseur) return;
let cancelled = false;
trackingRef.current = false;
clearPollTimer();
(async () => {
try {
const res = await fetch(
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
{ method: "GET", cache: "no-store" },
);
if (cancelled) return;
if (!res.ok) return;
const data: SyncJobState = await res.json();
if (cancelled) return;
if (data.status === "running" && data.jobId) {
trackingRef.current = true;
applyJobToUi(data);
startPolling();
} else if (data.status === "success" || data.status === "error") {
// Job terminé en amont (avant le mount) : on l'affiche brièvement
applyJobToUi(data);
setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
}, 4000);
}
} catch {
/* ignore : on n'empêche pas l'UI d'afficher le bouton */
}
})();
return () => {
cancelled = true;
trackingRef.current = false;
clearPollTimer();
};
// On veut interroger une seule fois au mount (et quand le fournisseur change).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [codeFournisseur, clearPollTimer]);
/**
* Cleanup : annule un timer de polling éventuel au démontage.
*/
useEffect(() => {
return () => {
clearPollTimer();
};
}, [clearPollTimer]);
const handleSync = async () => {
if (!codeFournisseur) {
setStatus("error");
setMessage("Aucun fournisseur sélectionné");
setTimeout(() => setStatus("idle"), 6000);
return;
}
// Empêche un double-clic pendant qu'un job tourne déjà.
if (status === "running") return;
setStatus("running");
setMessage("Démarrage de l'extraction Qlik…");
try {
const res = await fetch(
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
{ method: "POST" },
);
const data: SyncJobState & { success?: boolean } = await res.json();
if (!res.ok || (data as { success?: boolean }).success === false) {
throw new Error(data.error || `HTTP ${res.status}`);
}
// Si le serveur nous dit que c'est déjà en cours (jobId existant),
// on reprend juste le suivi.
if (data.status === "running" && data.jobId) {
trackingRef.current = true;
applyJobToUi(data);
startPolling();
return;
}
// Status terminal direct (cas rare : 0 codes → success immédiat)
applyJobToUi(data);
if (data.status === "success") {
refreshGrid();
}
setTimeout(() => setStatus("idle"), 4000);
} catch (e) {
setStatus("error");
setMessage(e instanceof Error ? e.message : String(e));
setTimeout(() => setStatus("idle"), 6000);
}
};
const { status, message, start } = useQlikSyncJob({
target: { mode: "fournisseur", fournisseur: codeFournisseur },
onSuccess: refreshGrid,
});
return (
<div className="flex items-center gap-2">
@@ -264,13 +56,13 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
? message
: status === "running"
? (message || `Extraction Qlik…`)
: `MAJ Qlik · ${formatDate(lastUpdate)}`}
: `MAJ Qlik · ${formatLastUpdate(lastUpdate)}`}
</span>
<button
onClick={handleSync}
onClick={start}
disabled={status === "running"}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatDate(lastUpdate)}`}
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatLastUpdate(lastUpdate)}`}
>
{status === "running" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
+96
View File
@@ -0,0 +1,96 @@
/**
* CollectFlow — Helpers de fenêtre mensuelle et de libellés magasin.
*
* Extraits de `heatmap-grid.tsx` pour être partagés entre la Grille et la
* fiche produit (`/produits`). Aucun changement de comportement.
*
* ⚠️ Deux conventions de clé de mois cohabitent dans l'application :
* - séries FF Nancy (ventes / stock / réceptions) : `"YYYYMM"` (sans tiret)
* - séries réseau Qlik (`qteByMonth`, `metricsByMonth`) : `"YYYY-MM"` (avec tiret)
* Utiliser `ffMonthToQlik` / `qlikMonthToFf` pour passer de l'une à l'autre.
*/
/**
* Les 12 mois complets glissants, du plus ancien au plus récent, au format
* `"YYYYMM"`. Le mois en cours est **exclu** (on part de i=12 jusqu'à i=1).
*
* ⚠️ À n'appeler qu'à l'intérieur d'un `useMemo` / d'un rendu, jamais au niveau
* module : `new Date()` évalué à l'import provoque un mismatch d'hydratation
* entre le rendu serveur et le rendu client.
*/
export function getLast12Months(): string[] {
const months: string[] = [];
const now = new Date();
// On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier)
// pour exclure le mois en cours (i=0)
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;
}
/** `"202601"` → `"Jan 26"`. */
export function formatMonthLabel(key: string): string {
const m = parseInt(key.slice(4, 6), 10);
const names = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
return `${names[m - 1]} ${key.slice(2, 4)}`;
}
/** `"2026-01"` → `"01/26"` — axe compact des graphiques. */
export function fmtMonthShort(lab: string): string {
const [y, m] = lab.split("-");
return `${m ?? lab}/${(y ?? "").slice(2)}`;
}
/** Date ISO → `jj/mm/aaaa`, `"—"` si absente ou invalide. */
export function formatDate(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
if (isNaN(d.getTime())) return "—";
return d.toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" });
}
/** Clé de mois FF `"202601"` → clé Qlik `"2026-01"`. */
export function ffMonthToQlik(key: string): string {
return `${key.slice(0, 4)}-${key.slice(4, 6)}`;
}
/** Clé de mois Qlik `"2026-01"` → clé FF `"202601"`. */
export function qlikMonthToFf(key: string): string {
return key.replace("-", "");
}
/** Les deux magasins FF Nancy suivis par l'application. */
export const SITE_LABELS: Record<string, { label: string; nom: string }> = {
"292": { label: "F", nom: "Frouard (Nancy)" },
"579": { label: "H", nom: "Houdemont" },
};
/**
* Style de badge stable pour un magasin : la couleur est dérivée d'un hash du
* nom, donc identique quel que soit l'ordre d'affichage.
*/
export function getStoreConfig(name: string) {
const known = SITE_LABELS[name];
const words = (known?.nom ?? name).trim().split(/\s+/);
let label = known?.label ?? words[0].charAt(0).toUpperCase();
if (!known && words.length > 1) {
label += words[1].charAt(0).toUpperCase();
}
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
const colorInt = Math.abs(hash) % 5;
switch (colorInt) {
case 0: return { bg: "rgba(99, 102, 241, 0.1)", text: "#818cf8", border: "rgba(99, 102, 241, 0.2)", label }; // Indigo
case 1: return { bg: "rgba(245, 158, 11, 0.1)", text: "#fbbf24", border: "rgba(245, 158, 11, 0.2)", label }; // Amber
case 2: return { bg: "rgba(16, 185, 129, 0.1)", text: "#10b981", border: "rgba(16, 185, 129, 0.2)", label }; // Emerald
case 3: return { bg: "rgba(236, 72, 153, 0.1)", text: "#ec4899", border: "rgba(236, 72, 153, 0.2)", label }; // Pink
case 4: return { bg: "rgba(14, 165, 233, 0.1)", text: "#0ea5e9", border: "rgba(14, 165, 233, 0.2)", label }; // Sky
default: return { bg: "var(--bg-elevated)", text: "var(--text-muted)", border: "var(--border)", label };
}
}
+78
View File
@@ -0,0 +1,78 @@
/**
* CollectFlow — Tendance des ventes réseau Qlik sur 12 mois.
*
* Extrait de `heatmap-grid.tsx` pour être partagé entre la Grille (sparkline de
* la colonne « Tendance / Réseau ») et la fiche produit (`/produits`).
* Aucun changement de comportement.
*/
/**
* Nombre de magasins du réseau La Foir'Fouille servant de dénominateur au taux
* de présence. Constante métier : source unique pour la Grille et la fiche produit.
*/
export const NB_MAGASINS_RESEAU = 270;
export type NetworkTrend = {
values: number[]; // quantités mensuelles, ordre chronologique
labels: string[]; // "YYYY-MM"
direction: "up" | "down" | "flat";
pct: number | null; // variation modélisée (régression linéaire) sur 12 mois
hasData: boolean;
};
/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */
export const TREND_STRONG = 0.25;
/** Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%). */
export const TREND_FLAT = 0.08;
export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
up: "#22c55e",
down: "#ef4444",
flat: "#94a3b8",
};
/**
* Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois.
* Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la
* variation modélisée sur la période (pente × durée) rapportée à la moyenne.
* Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
*/
export function computeNetworkTrend(qteByMonth?: Record<string, number> | null): NetworkTrend {
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
if (!qteByMonth) return empty;
let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
if (labels.length === 0) return empty;
labels = labels.slice(-12); // 12 derniers mois
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
const n = values.length;
let pct: number | null = null;
let direction: NetworkTrend["direction"] = "flat";
if (n >= 3) {
const mx = (n - 1) / 2;
const my = values.reduce((s, v) => s + v, 0) / n;
let num = 0, den = 0;
for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); }
const slope = den ? num / den : 0;
if (my > 0) {
pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne
direction = pct > TREND_FLAT ? "up" : pct < -TREND_FLAT ? "down" : "flat";
} else if (slope > 0) {
direction = "up";
}
} else if (n === 2 && values[0] > 0) {
pct = (values[1] - values[0]) / values[0];
direction = pct > TREND_FLAT ? "up" : pct < -TREND_FLAT ? "down" : "flat";
}
return { values, labels, direction, pct, hasData: true };
}
/** Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »… */
export function trendLabel(pct: number | null): string {
if (pct == null) return "Stable";
if (pct > TREND_STRONG) return "Forte hausse";
if (pct > TREND_FLAT) return "Hausse";
if (pct < -TREND_STRONG) return "Forte baisse";
if (pct < -TREND_FLAT) return "Baisse";
return "Stable";
}
@@ -0,0 +1,154 @@
import "server-only";
import {
pgGetProduitDetail,
pgGetFournisseursByCodein,
pgGetStockByCodein,
pgGetCommandesByCodein,
pgGetGammeHistoryByCodein,
pgGetMensuelByCodein,
} from "@/lib/pg-ff-client";
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
import type { ProduitFiche, ProduitMois, ProduitTotaux } from "@/features/produits/types";
/** Les 12 clés de mois "YYYYMM" glissantes, mois courant exclu. */
function buildMonthKeys(): string[] {
const months: string[] = [];
const now = new Date();
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;
}
function emptyMois(mois: string): ProduitMois {
return { mois, qte: 0, ca: 0, marge: 0, stockFinMois: 0, qteRecue: 0 };
}
function totauxFrom(rows: ProduitMois[]): ProduitTotaux {
const qte = rows.reduce((s, r) => s + r.qte, 0);
const ca = rows.reduce((s, r) => s + r.ca, 0);
const marge = rows.reduce((s, r) => s + r.marge, 0);
return { qte, ca, marge, tauxMarge: ca > 0 ? (marge / ca) * 100 : 0 };
}
/**
* Assemble la fiche complète d'un produit : identité, fournisseurs, stock,
* gammes, 12 mois glissants de ventes (total et par magasin) et métriques
* réseau Qlik.
*
* Renvoie `null` si le codein est inconnu du catalogue FF.
*/
export async function getProduitFiche(codein: string): Promise<ProduitFiche | null> {
const cleaned = codein.trim();
if (!cleaned) return null;
const detail = await pgGetProduitDetail(cleaned);
if (!detail) return null;
const { dateDebut, dateFin } = buildLast12MonthsRange();
const [fournisseurs, stock, commandesEnCours, gammes, mensuelRows] = await Promise.all([
pgGetFournisseursByCodein(cleaned),
pgGetStockByCodein(cleaned),
pgGetCommandesByCodein(cleaned),
pgGetGammeHistoryByCodein(cleaned),
pgGetMensuelByCodein(cleaned, dateDebut, dateFin),
]);
const months = buildMonthKeys();
const allowed = new Set(months);
const mensuelTotal: Record<string, ProduitMois> = {};
const mensuelParSite: Record<string, Record<string, ProduitMois>> = {};
for (const m of months) mensuelTotal[m] = emptyMois(m);
// Mois réellement présents dans les mouvements. Indispensable pour distinguer
// « pas de mouvement ce mois-là » (→ report du stock précédent) de « stock
// effectivement nul » — c'est la convention de la Grille.
const moisAvecDonnees = new Set<string>();
const moisAvecDonneesParSite = new Map<string, Set<string>>();
for (const row of mensuelRows) {
// SQL renvoie "YYYY-MM", la timeline de l'app utilise "YYYYMM".
const mois = row.mois.replace("-", "");
if (!allowed.has(mois)) continue;
const site = String(row.site).trim();
const qte = Number(row.qte_vendue) || 0;
const ca = Number(row.ca_ht) || 0;
const marge = Number(row.marge) || 0;
const stockFinMois = Number(row.stock_fin_mois) || 0;
const qteRecue = Number(row.qte_recue) || 0;
moisAvecDonnees.add(mois);
const tot = mensuelTotal[mois];
tot.qte += qte;
tot.ca += ca;
tot.marge += marge;
tot.stockFinMois += stockFinMois;
tot.qteRecue += qteRecue;
mensuelParSite[site] ??= Object.fromEntries(months.map((m) => [m, emptyMois(m)]));
if (!moisAvecDonneesParSite.has(site)) moisAvecDonneesParSite.set(site, new Set());
moisAvecDonneesParSite.get(site)!.add(mois);
const cell = mensuelParSite[site][mois];
cell.qte += qte;
cell.ca += ca;
cell.marge += marge;
// Le SQL donne déjà le stock du dernier mouvement du mois pour ce site.
cell.stockFinMois = stockFinMois;
cell.qteRecue += qteRecue;
}
// Report du stock de fin de mois sur les mois SANS mouvement (carry-forward),
// même convention que la Grille : un mois sans mouvement conserve le stock du
// mois précédent, alors qu'un mois avec mouvements et un stock nul reste à 0.
const carryForward = (serie: Record<string, ProduitMois>, avecDonnees: Set<string>) => {
let last = 0;
for (const m of months) {
if (avecDonnees.has(m)) last = serie[m].stockFinMois;
else serie[m].stockFinMois = last;
}
};
carryForward(mensuelTotal, moisAvecDonnees);
for (const [site, bySite] of Object.entries(mensuelParSite)) {
carryForward(bySite, moisAvecDonneesParSite.get(site) ?? new Set());
}
const totaux = totauxFrom(months.map((m) => mensuelTotal[m]));
const totauxParSite: Record<string, ProduitTotaux> = {};
for (const [site, bySite] of Object.entries(mensuelParSite)) {
totauxParSite[site] = totauxFrom(months.map((m) => bySite[m]));
}
const sitesActifs = Object.entries(totauxParSite)
.filter(([, t]) => t.qte > 0)
.map(([site]) => site)
.sort();
// Métriques réseau Qlik (jointure par code centrale).
let reseau = null;
if (detail.code_centrale) {
const map = await getNetworkMetricsByCodeCentrale([detail.code_centrale]);
reseau = map.get(detail.code_centrale) ?? null;
}
return {
detail,
fournisseurs,
stock,
commandesEnCours,
gammes,
months,
mensuelTotal,
mensuelParSite,
totaux,
totauxParSite,
sitesActifs,
reseau,
periode: { dateDebut, dateFin },
};
}
+147
View File
@@ -0,0 +1,147 @@
/**
* CollectFlow — Comparatif local vs réseau pour un produit.
*
* Principe : rapporter les deux périmètres à un **magasin moyen**, sinon on
* compare 2 magasins Nancy à ~270 magasins réseau et le chiffre ne veut rien dire.
*
* ⚠️ La comparaison de référence porte sur les **quantités**. Le CA local vient
* de `mvtart.mntmvtttc` (TTC) alors que la base de la mesure Qlik « CA N » n'est
* pas documentée : les montants sont donc affichés côte à côte à titre indicatif,
* sans indice calculé.
*/
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { ffMonthToQlik } from "@/features/grid/lib/months";
import type { ProduitFiche } from "@/features/produits/types";
export interface ComparatifMois {
/** Clé de mois FF "YYYYMM". */
mois: string;
/** Quantité vendue par magasin, chez nous. */
local: number;
/** Quantité vendue par magasin, au réseau. */
reseau: number;
}
export interface ComparatifReseau {
/** false si le produit n'a pas encore de données réseau en cache. */
hasReseau: boolean;
/** Nombre de nos magasins ayant vendu le produit (min. 1 pour éviter /0). */
nbMagasinsLocaux: number;
nbMagasinsReseau: number;
/** Taux de présence réseau (0..1), ou null sans données. */
tauxPresence: number | null;
/** Quantité 12 mois, par magasin. */
qteLocaleParMagasin: number;
qteReseauParMagasin: number;
/**
* Indice de performance en % : 100 = on vend autant qu'un magasin moyen du
* réseau, 150 = 50 % de mieux. `null` si le réseau ne vend rien.
*/
indiceQte: number | null;
/** CA 12 mois par magasin — indicatif (bases de calcul non alignées). */
caLocalParMagasin: number;
caReseauParMagasin: number | null;
/** Taux de marge en %, local et réseau (indicatif). */
tauxMargeLocal: number;
tauxMargeReseau: number | null;
/** Série mensuelle par magasin, alignée sur les 12 mois de la fiche. */
monthly: ComparatifMois[];
/** true si au moins un mois réseau est disponible. */
hasMonthly: boolean;
}
/**
* Le taux de marge Qlik est un ratio brut (0.32 = 32 %) mais certaines
* configurations renvoient déjà des points de pourcentage. Même normalisation
* que la colonne « Marge % / Réseau » de la Grille.
*/
export function normalizeMargePct(raw: number | null | undefined): number | null {
if (raw == null) return null;
const v = Number(raw);
if (!Number.isFinite(v) || v === 0) return null;
return Math.abs(v) <= 1 ? v * 100 : v;
}
/** Calcule le comparatif local vs réseau à partir d'une fiche produit. */
export function computeComparatif(fiche: ProduitFiche): ComparatifReseau {
const { reseau, totaux, sitesActifs, months, mensuelTotal } = fiche;
// Nos magasins actifs sur la période. Si aucun n'a vendu, on garde 1 pour
// que la division reste définie — l'indice vaudra alors 0, ce qui est le
// message voulu : « le réseau vend, nous non ».
const nbMagasinsLocaux = Math.max(sitesActifs.length, 1);
const qteLocaleParMagasin = totaux.qte / nbMagasinsLocaux;
const caLocalParMagasin = totaux.ca / nbMagasinsLocaux;
if (!reseau || !reseau.nbMagasinsReseau) {
return {
hasReseau: false,
nbMagasinsLocaux,
nbMagasinsReseau: 0,
tauxPresence: null,
qteLocaleParMagasin,
qteReseauParMagasin: 0,
indiceQte: null,
caLocalParMagasin,
caReseauParMagasin: null,
tauxMargeLocal: totaux.tauxMarge,
tauxMargeReseau: null,
monthly: [],
hasMonthly: false,
};
}
const nbMagasinsReseau = reseau.nbMagasinsReseau;
const qteReseauParMagasin = reseau.qteReseau / nbMagasinsReseau;
const caReseauParMagasin = reseau.caReseau / nbMagasinsReseau;
// Série mensuelle : quantité par magasin de part et d'autre.
// Le nb de magasins réseau du mois est utilisé quand il est disponible
// (`metricsByMonth`), sinon on retombe sur le total de la période.
const monthly: ComparatifMois[] = [];
let hasMonthly = false;
for (const mois of months) {
const qlikKey = ffMonthToQlik(mois);
const mensuel = reseau.metricsByMonth?.[qlikKey];
const qteReseauMois = mensuel?.qte ?? reseau.qteByMonth?.[qlikKey];
const nbMagMois = mensuel?.nbMag && mensuel.nbMag > 0 ? mensuel.nbMag : nbMagasinsReseau;
if (qteReseauMois != null) hasMonthly = true;
monthly.push({
mois,
local: (mensuelTotal[mois]?.qte ?? 0) / nbMagasinsLocaux,
reseau: qteReseauMois != null ? qteReseauMois / nbMagMois : 0,
});
}
return {
hasReseau: true,
nbMagasinsLocaux,
nbMagasinsReseau,
tauxPresence: nbMagasinsReseau / NB_MAGASINS_RESEAU,
qteLocaleParMagasin,
qteReseauParMagasin,
indiceQte: qteReseauParMagasin > 0 ? (qteLocaleParMagasin / qteReseauParMagasin) * 100 : null,
caLocalParMagasin,
caReseauParMagasin,
tauxMargeLocal: totaux.tauxMarge,
tauxMargeReseau: normalizeMargePct(reseau.margePctReseau),
monthly,
hasMonthly,
};
}
/** Lecture métier de l'indice de performance. */
export function indiceVerdict(indice: number | null): { label: string; color: string } {
if (indice == null) return { label: "Non comparable", color: "var(--text-muted)" };
if (indice >= 150) return { label: "Forte surperformance", color: "#22c55e" };
if (indice >= 110) return { label: "Surperformance", color: "#22c55e" };
if (indice >= 90) return { label: "Dans la moyenne réseau", color: "var(--text-secondary)" };
if (indice >= 50) return { label: "Sous-performance", color: "#f59e0b" };
return { label: "Forte sous-performance", color: "#ef4444" };
}
+53
View File
@@ -0,0 +1,53 @@
/**
* CollectFlow — Types de la fiche produit (page /produits).
*/
import type {
PgProduitDetailRow,
PgProduitFournisseurRow,
PgProduitStockRow,
PgGammeHistoryRow,
} from "@/lib/pg-ff-client";
import type { NetworkMetricCached } from "@/lib/qlik-network-cache";
/** 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". */
mois: string;
qte: number;
/** Issu de mntmvtttc — TTC malgré le nom historique `ca_ht`. */
ca: number;
marge: number;
stockFinMois: number;
qteRecue: number;
}
export interface ProduitTotaux {
qte: number;
ca: number;
marge: number;
/** Taux de marge en % (marge / CA × 100). */
tauxMarge: number;
}
export interface ProduitFiche {
detail: PgProduitDetailRow;
fournisseurs: PgProduitFournisseurRow[];
stock: PgProduitStockRow[];
commandesEnCours: number;
gammes: PgGammeHistoryRow[];
/** Les 12 clés de mois "YYYYMM", du plus ancien au plus récent. */
months: string[];
/** Cumul tous sites, par mois. */
mensuelTotal: Record<string, ProduitMois>;
/** site → mois → métriques. */
mensuelParSite: Record<string, Record<string, ProduitMois>>;
totaux: ProduitTotaux;
totauxParSite: Record<string, ProduitTotaux>;
/** Sites ayant vendu au moins une unité sur les 12 mois. */
sitesActifs: string[];
/** Métriques réseau Qlik, `null` si le produit n'est pas encore en cache. */
reseau: NetworkMetricCached | null;
/** Fenêtre analysée, pour l'affichage. */
periode: { dateDebut: string; dateFin: string };
}
+223
View File
@@ -0,0 +1,223 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
/** État serveur renvoyé par GET/POST /api/qlik/sync. */
export interface QlikSyncJobState {
jobId: string | null;
fournisseur: string;
codeCentrale?: string;
status: "idle" | "running" | "success" | "error";
requested: number;
fetched: number;
upserted: number;
periode?: string;
dateDebut?: string;
dateFin?: string;
startedAt?: string;
finishedAt?: string;
error?: string;
message?: string;
}
export type QlikSyncStatus = QlikSyncJobState["status"];
/**
* Cible de la synchro : un fournisseur entier (réservé aux admins) ou un seul
* code centrale (ouvert à tout utilisateur connecté).
*/
export type QlikSyncTarget =
| { mode: "fournisseur"; fournisseur: string | undefined }
| { mode: "produit"; codeCentrale: string | undefined };
interface UseQlikSyncJobOptions {
target: QlikSyncTarget;
/** Appelé une fois quand un job se termine avec succès. */
onSuccess?: () => void;
/** Intervalle de polling en ms (défaut 3 s — une extraction peut durer plusieurs minutes). */
pollIntervalMs?: number;
}
/** Construit la query string de la route selon la cible. */
function targetQuery(target: QlikSyncTarget): string | null {
if (target.mode === "fournisseur") {
return target.fournisseur ? `fournisseur=${encodeURIComponent(target.fournisseur)}` : null;
}
return target.codeCentrale ? `codeCentrale=${encodeURIComponent(target.codeCentrale)}` : null;
}
/**
* Pilote un job de synchronisation Qlik : démarrage (POST), suivi (polling GET),
* reprise d'un job déjà en cours au montage, et nettoyage des timers.
*
* Extrait de `SyncQlikButton` pour être partagé avec la fiche produit. Le
* protocole est inchangé : le POST démarre un job serveur en arrière-plan et
* revient immédiatement, le client poll ensuite GET jusqu'à l'état terminal.
*/
export function useQlikSyncJob({ target, onSuccess, pollIntervalMs = 3000 }: UseQlikSyncJobOptions) {
const [status, setStatus] = useState<QlikSyncStatus>("idle");
const [message, setMessage] = useState<string>("");
const query = targetQuery(target);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */
const trackingRef = useRef<boolean>(false);
// Gardé dans une ref : le callback peut changer d'identité à chaque rendu
// sans qu'on veuille relancer le polling pour autant.
const onSuccessRef = useRef(onSuccess);
useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
const clearPollTimer = useCallback(() => {
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
/** Met à jour le libellé visible selon l'état courant. */
const applyJobToUi = useCallback((j: QlikSyncJobState | null) => {
if (!j) return;
if (j.status === "running") {
setStatus("running");
const requestedTxt = j.requested > 0 ? ` (${j.requested} code${j.requested > 1 ? "s" : ""})` : "";
setMessage(`Extraction Qlik en cours${requestedTxt}…`);
} else if (j.status === "success") {
setStatus("success");
if (j.message) setMessage(j.message);
else setMessage(`${j.upserted} produits réseau synchronisés`);
} else if (j.status === "error") {
setStatus("error");
setMessage(j.error || "Erreur sync Qlik");
} else {
setStatus("idle");
}
}, []);
/**
* Un tour de polling. Renvoie true s'il faut continuer.
*/
const pollOnce = useCallback(async (): Promise<boolean> => {
if (!query || !trackingRef.current) return false;
try {
const res = await fetch(`/api/qlik/sync?${query}`, { method: "GET", cache: "no-store" });
if (!res.ok) {
// Erreur HTTP transitoire : on retente au tour suivant.
return true;
}
const data: QlikSyncJobState = await res.json();
applyJobToUi(data);
if (data.status === "running") return true;
if (data.status === "success" || data.status === "error") {
trackingRef.current = false;
if (data.status === "success") onSuccessRef.current?.();
// Petit délai pour laisser l'utilisateur lire le message vert/rouge.
setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
}, 4000);
return false;
}
trackingRef.current = false;
return false;
} catch {
// Erreur réseau transitoire : on laisse le polling réessayer.
return true;
}
}, [applyJobToUi, query]);
/** Boucle de polling auto-réarmée tant que pollOnce le demande. */
const startPolling = useCallback(() => {
clearPollTimer();
const tick = async () => {
const keepGoing = await pollOnce();
if (keepGoing && trackingRef.current) {
pollTimerRef.current = setTimeout(tick, pollIntervalMs);
} else {
pollTimerRef.current = null;
}
};
// Premier appel quasi immédiat.
pollTimerRef.current = setTimeout(tick, 200);
}, [clearPollTimer, pollOnce, pollIntervalMs]);
/**
* Au montage (et quand la cible change) : reprend l'état d'un éventuel job
* déjà en cours, par exemple après un reload pendant l'extraction.
*/
useEffect(() => {
if (!query) return;
let cancelled = false;
trackingRef.current = false;
clearPollTimer();
(async () => {
try {
const res = await fetch(`/api/qlik/sync?${query}`, { method: "GET", cache: "no-store" });
if (cancelled || !res.ok) return;
const data: QlikSyncJobState = await res.json();
if (cancelled) return;
if (data.status === "running" && data.jobId) {
trackingRef.current = true;
applyJobToUi(data);
startPolling();
} else if (data.status === "success" || data.status === "error") {
// Job terminé avant le montage : on l'affiche brièvement.
applyJobToUi(data);
setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
}, 4000);
}
} catch {
/* ignore : on n'empêche pas l'UI de s'afficher */
}
})();
return () => {
cancelled = true;
trackingRef.current = false;
clearPollTimer();
};
// On veut interroger une seule fois au montage (et quand la cible change).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, clearPollTimer]);
/** Cleanup : annule un timer de polling éventuel au démontage. */
useEffect(() => clearPollTimer, [clearPollTimer]);
/** Démarre l'extraction. */
const start = useCallback(async () => {
if (!query) {
setStatus("error");
setMessage(target.mode === "fournisseur" ? "Aucun fournisseur sélectionné" : "Aucun code centrale");
setTimeout(() => setStatus("idle"), 6000);
return;
}
// Empêche un double-clic pendant qu'un job tourne déjà.
if (status === "running") return;
setStatus("running");
setMessage("Démarrage de l'extraction Qlik…");
try {
const res = await fetch(`/api/qlik/sync?${query}`, { method: "POST" });
const data: QlikSyncJobState & { success?: boolean } = await res.json();
if (!res.ok || data.success === false) {
throw new Error(data.error || `HTTP ${res.status}`);
}
if (data.status === "running" && data.jobId) {
trackingRef.current = true;
applyJobToUi(data);
startPolling();
return;
}
// Statut terminal direct (cas rare : 0 code → success immédiat).
applyJobToUi(data);
if (data.status === "success") onSuccessRef.current?.();
setTimeout(() => setStatus("idle"), 4000);
} catch (e) {
setStatus("error");
setMessage(e instanceof Error ? e.message : String(e));
setTimeout(() => setStatus("idle"), 6000);
}
}, [applyJobToUi, query, startPolling, status, target.mode]);
return { status, message, start };
}
+513 -5
View File
@@ -273,9 +273,17 @@ export interface PgNomRow {
* Colonnes confirmées : nomenclature.code, nomenclature.libelle
* Colonne parent : découverte dynamiquement via information_schema
*/
export async function pgGetNomenclatureByFournisseur(codefou: string): Promise<Map<string, PgNomRow>> {
// Découverte de la colonne parent : mise en cache module-level (1 seule fois par process)
if (_nomenclatureParentCol === undefined) {
/**
* Découvre (une seule fois par process) le nom de la colonne "parent" de la
* table `nomenclature`, qui porte la FK vers le `no_id` du niveau supérieur.
*
* On ne peut pas la coder en dur : le nom varie selon les installations
* ("chemin_pere" existe mais c'est un chemin *texte* matérialisé, pas la FK),
* d'où le filtre explicite sur `data_type` entier.
*/
async function getNomenclatureParentCol(): Promise<string | null> {
if (_nomenclatureParentCol !== undefined) return _nomenclatureParentCol;
const metaResult = await db.execute(sql`
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'nomenclature' ORDER BY ordinal_position
@@ -291,8 +299,11 @@ export async function pgGetNomenclatureByFournisseur(codefou: string): Promise<M
);
_nomenclatureParentCol = parentRow?.column_name ?? null;
console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol);
}
const parentCol = _nomenclatureParentCol;
return _nomenclatureParentCol;
}
export async function pgGetNomenclatureByFournisseur(codefou: string): Promise<Map<string, PgNomRow>> {
const parentCol = await getNomenclatureParentCol();
let result;
if (parentCol) {
@@ -1226,3 +1237,500 @@ export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string
return map;
}
}
// ---------------------------------------------------------------------------
// 13. Recherche & fiche produit (page /produits)
// ---------------------------------------------------------------------------
/**
* Une ligne de résultat de recherche produit. Volontairement **légère** :
* aucune agrégation `mvtart` ici (le seq-scan du ILIKE domine déjà le coût).
* Le détail lourd n'est calculé qu'à l'ouverture de la fiche.
*/
export interface PgProduitSearchRow {
no_id: number;
codein: string;
code_centrale: string;
libelle1: string;
/** Nom du fournisseur principal (artfou1.preference = 1), sinon son code. */
fournisseur: string;
codefou: string;
nomenclature_code: string;
nomenclature: string;
stock_total: number;
/** true si le produit a déjà des métriques réseau Qlik en cache. */
has_reseau: boolean;
qte_reseau: number;
nb_magasins_reseau: number;
}
/**
* Recherche un produit par **libellé** (sous-chaîne) ou par **code centrale**
* (préfixe). Si le terme est entièrement numérique, on tente aussi une
* correspondance exacte sur le `codein`.
*
* ⚠️ Il n'existe aucun index texte sur `articles.libelle1` (base FF resynchronisée
* chaque nuit depuis SQL Server — on n'y crée pas d'index). La requête fait donc
* un seq-scan : l'appelant impose un minimum de 3 caractères et une soumission
* explicite, jamais une recherche à la frappe.
*/
export async function pgSearchProduits(term: string, limit = 50): Promise<PgProduitSearchRow[]> {
const cleaned = term.trim();
if (cleaned.length < 3) return [];
const like = `%${cleaned}%`;
const prefix = `${cleaned}%`;
// Un terme purement numérique peut être un codein : on l'ajoute au filtre
// (correspondance exacte, la colonne est space-padded d'où le TRIM).
const codeinClause = /^\d+$/.test(cleaned)
? sql`OR TRIM(a.codein::text) = ${cleaned}`
: sql``;
try {
const result = await pgNoParallel(sql`
WITH raw AS (
SELECT DISTINCT ON (TRIM(a.codein::text))
a.no_id,
TRIM(a.codein::text) AS codein,
COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale,
COALESCE(a.libelle1, '') AS libelle1,
a.nom_no_id
FROM articles a
WHERE a.codein IS NOT NULL
AND (
a.libelle1 ILIKE ${like}
OR TRIM(a.artcentrale::text) ILIKE ${prefix}
${codeinClause}
)
ORDER BY TRIM(a.codein::text), a.no_id DESC
),
matched AS (
SELECT * FROM raw ORDER BY libelle1 LIMIT ${limit}
)
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,
(q.code_centrale IS NOT NULL) AS has_reseau,
COALESCE(q.qte_reseau, 0)::float AS qte_reseau,
COALESCE(q.nb_magasins_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
LEFT JOIN qlik_network_metrics q ON q.code_centrale = m.code_centrale
ORDER BY m.libelle1
`);
console.log(`[pg-ff] pgSearchProduits("${cleaned}"): ${result.rows.length} résultats`);
return result.rows as unknown as PgProduitSearchRow[];
} catch (e) {
console.error("[pg-ff] pgSearchProduits error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/** Identité, prix et nomenclature d'un produit (une seule ligne). */
export interface PgProduitDetailRow {
no_id: number;
codein: string;
code_centrale: string;
libelle1: string;
nom_no_id: number | null;
gtin: string;
reference: string;
pcb: number | null;
/** article_infosup.prix_vente_mini */
pv_central: number | null;
/** cube_pa.pa */
pa: number | null;
code3: string | null;
libelle3: string | null;
code2: string | null;
libelle2: string | null;
code1: string | null;
libelle1nom: string | null;
}
/**
* Fiche d'identité complète d'un produit à partir de son `codein`.
* Renvoie `null` si le codein est inconnu.
*/
export async function pgGetProduitDetail(codein: string): Promise<PgProduitDetailRow | null> {
const parentCol = await getNomenclatureParentCol();
// Hiérarchie nomenclature : seulement si la colonne parent a été trouvée,
// sinon on se limite au niveau feuille (même dégradation que pgGetNomenclatureByFournisseur).
const hierarchy = parentCol
? sql`
LEFT JOIN nomenclature n2 ON n2.no_id = n3.${sql.raw(parentCol)}
LEFT JOIN nomenclature n1 ON n1.no_id = n2.${sql.raw(parentCol)}
`
: sql``;
const hierarchyCols = parentCol
? sql`,
n2.code AS code2,
n2.libelle AS libelle2,
n1.code AS code1,
n1.libelle AS libelle1nom`
: sql`,
NULL::text AS code2,
NULL::text AS libelle2,
NULL::text AS code1,
NULL::text AS libelle1nom`;
try {
const result = await pgNoParallel(sql`
SELECT
a.no_id,
TRIM(a.codein::text) AS codein,
COALESCE(TRIM(a.artcentrale::text), '') AS code_centrale,
COALESCE(a.libelle1, '') AS libelle1,
a.nom_no_id,
COALESCE(ag.gtin, af.ean13, '')::text AS gtin,
COALESCE(af.reference, '')::text AS reference,
af.pcb::float AS pcb,
ai.prix_vente_mini::float AS pv_central,
pa.pa::float AS pa,
n3.code AS code3,
n3.libelle AS libelle3
${hierarchyCols}
FROM articles a
LEFT JOIN LATERAL (
SELECT af1.code, af1.pcb, af1.reference, af1.ean13
FROM artfou1 af1
WHERE af1.art_no_id = a.no_id
ORDER BY af1.preference DESC NULLS LAST, af1.no_id DESC
LIMIT 1
) af ON TRUE
LEFT JOIN art_gtin ag ON ag.idarticle = a.no_id AND ag.preferentiel = 1
LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id
LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id
LEFT JOIN nomenclature n3 ON n3.no_id = a.nom_no_id
${hierarchy}
WHERE TRIM(a.codein::text) = ${codein}
ORDER BY a.no_id DESC
LIMIT 1
`);
const row = (result.rows as unknown as PgProduitDetailRow[])[0] ?? null;
console.log(`[pg-ff] pgGetProduitDetail(${codein}): ${row ? "trouvé" : "introuvable"}`);
return row;
} catch (e) {
console.error("[pg-ff] pgGetProduitDetail error:", (e as Error).message?.slice(0, 250));
return null;
}
}
/** Un fournisseur référencé pour l'article. */
export interface PgProduitFournisseurRow {
codefou: string;
nomfou: string;
reference: string;
pcb: number | null;
/** true si artfou1.preference = 1 */
principal: boolean;
}
/** Tous les fournisseurs référencés pour un article (le principal en premier). */
export async function pgGetFournisseursByCodein(codein: string): Promise<PgProduitFournisseurRow[]> {
try {
const result = await pgNoParallel(sql`
SELECT DISTINCT ON (af.code)
af.code::text AS codefou,
COALESCE(fi.nom, af.code)::text AS nomfou,
COALESCE(af.reference, '')::text AS reference,
af.pcb::float AS pcb,
(af.preference = 1) AS principal
FROM articles a
JOIN artfou1 af ON af.art_no_id = a.no_id
LEFT JOIN fouident fi ON fi.code = af.code
WHERE TRIM(a.codein::text) = ${codein}
ORDER BY af.code, af.preference DESC NULLS LAST, af.no_id DESC
`);
const rows = result.rows as unknown as PgProduitFournisseurRow[];
// Le principal remonte en tête (le DISTINCT ON impose un tri par code).
rows.sort((x, y) => Number(y.principal) - Number(x.principal) || x.nomfou.localeCompare(y.nomfou, "fr"));
return rows;
} catch (e) {
console.error("[pg-ff] pgGetFournisseursByCodein error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/** Stock temps réel par site pour un article. */
export interface PgProduitStockRow {
site: string;
stockdispo: number;
qte: number;
valstock: number;
prmp: number;
dernierevente: string | null;
dernierereception: string | null;
}
export async function pgGetStockByCodein(codein: string): Promise<PgProduitStockRow[]> {
try {
const result = await pgNoParallel(sql`
SELECT
cs.site,
COALESCE(cs.stockdispo, 0)::float AS stockdispo,
COALESCE(cs.qte, 0)::float AS qte,
COALESCE(cs.valstock, 0)::float AS valstock,
COALESCE(cs.prmp, 0)::float AS prmp,
cs.dernierevente::text AS dernierevente,
cs.dernierereception::text AS dernierereception
FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid
WHERE TRIM(a.codein::text) = ${codein}
AND cs.site IN ('292', '579')
ORDER BY cs.site
`);
return result.rows as unknown as PgProduitStockRow[];
} catch (e) {
console.error("[pg-ff] pgGetStockByCodein error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/** Quantité totale en commande fournisseur pour un article. */
export async function pgGetCommandesByCodein(codein: string): Promise<number> {
try {
const result = await pgNoParallel(sql`
SELECT COALESCE(SUM(cv.cdefou_ligne_qtecde), 0)::float AS qtecde
FROM cdefou_vivant cv
WHERE TRIM(cv.articles_codein::text) = ${codein}
`);
const row = (result.rows as unknown as { qtecde: number }[])[0];
return Number(row?.qtecde) || 0;
} catch (e) {
console.error("[pg-ff] pgGetCommandesByCodein error:", (e as Error).message?.slice(0, 250));
return 0;
}
}
/** Une gamme affectée à l'article pour une saison donnée. */
export interface PgGammeHistoryRow {
saison_no_id: number;
saison_code: string | null;
saison_libelle: string | null;
gamme_code: string;
gamme_libelle: string | null;
}
/**
* Historique complet des gammes de l'article, saison par saison (la plus
* récente en premier). Le reste de l'application ne lit que la saison active
* (`pgGetGammesByFournisseur`) ; ici on veut la trajectoire d'arbitrage.
*/
export async function pgGetGammeHistoryByCodein(codein: string): Promise<PgGammeHistoryRow[]> {
try {
const result = await pgNoParallel(sql`
SELECT
s.no_id AS saison_no_id,
s.code::text AS saison_code,
s.libelle::text AS saison_libelle,
g.code::text AS gamme_code,
g.libelle::text AS gamme_libelle
FROM art_gamme_saison ags
JOIN articles a ON a.no_id = ags.artnoid
JOIN gammes g ON g.no_id = ags.idgamme
JOIN saisons s ON s.no_id = ags.idsaison
WHERE TRIM(a.codein::text) = ${codein}
ORDER BY s.no_id DESC
`);
return result.rows as unknown as PgGammeHistoryRow[];
} catch (e) {
console.error("[pg-ff] pgGetGammeHistoryByCodein error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/**
* Données mensuelles (ventes / CA / marge / stock fin de mois / réceptions)
* d'UN article, par site et par mois.
*
* Reprend **exactement** le calcul canonique de `pgGetMensuelByFournisseur`
* (la source de vérité de la Grille) : seule la sélection change — filtre sur
* le codein au lieu d'une jointure `artfou1`. Les chiffres affichés sur la
* fiche produit sont donc identiques à ceux de la Grille, par construction.
*/
export async function pgGetMensuelByCodein(
codein: string,
dateDebut: string,
dateFin: string
): Promise<PgMensuelRow[]> {
try {
const result = await pgNoParallel(sql`
WITH base AS (
SELECT
TRIM(a.codein::text) AS codein,
m.site,
TO_CHAR(m.datmvt, 'YYYY-MM') AS mois,
m.qtemvt,
m.mntmvtttc,
m.margemvt,
m.genremvt,
m.qtestock,
ROW_NUMBER() OVER (
PARTITION BY m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
ORDER BY m.datmvt DESC
) AS rn_last
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
WHERE TRIM(a.codein::text) = ${codein}
AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
AND m.site IN ('292', '579')
)
SELECT
codein,
site,
mois,
SUM(CASE WHEN genremvt = 3 THEN -qtemvt ELSE 0 END)::float AS qte_vendue,
SUM(CASE WHEN genremvt = 3 THEN -mntmvtttc ELSE 0 END)::float AS ca_ht,
SUM(CASE WHEN genremvt = 3 THEN margemvt ELSE 0 END)::float AS marge,
MAX(CASE WHEN rn_last = 1 THEN qtestock ELSE NULL END)::float AS stock_fin_mois,
SUM(CASE WHEN genremvt IN (1, 2) THEN qtemvt ELSE 0 END)::float AS qte_recue
FROM base
GROUP BY codein, site, mois
ORDER BY site, mois
`);
console.log(`[pg-ff] pgGetMensuelByCodein(${codein}): ${result.rows.length} lignes`);
return result.rows as unknown as PgMensuelRow[];
} catch (e) {
console.error("[pg-ff] pgGetMensuelByCodein error:", (e as Error).message?.slice(0, 250));
return [];
}
}
/** Un produit de la même famille, comparé local vs réseau. */
export interface PgOpportuniteRow {
codein: string;
libelle1: string;
code_centrale: string;
fournisseur: string;
qte_reseau: number;
nb_magasins_reseau: number;
ca_reseau: number;
ca_par_magasin_reseau: number;
qte_locale: number;
nb_sites_locaux: number;
qte_reseau_par_magasin: number;
qte_locale_par_magasin: number;
/** Écart réseau − local, par magasin, sur 12 mois. Positif = opportunité. */
ecart_par_magasin: number;
}
/**
* Produits de la même nomenclature, classés par écart entre la performance
* réseau et la performance locale (quantités **par magasin**, pour neutraliser
* la différence d'échelle entre ~270 magasins et nos 2 sites).
*
* Périmètre volontairement borné aux articles **de notre catalogue** ayant déjà
* des métriques réseau en cache : la dimension Qlik interrogée est
* `Article Code`, on ne peut pas découvrir un produit que Nancy ne référence pas.
*/
export async function pgGetOpportunitesFamille(
nomNoId: number,
dateDebut: string,
dateFin: string,
limit = 30
): Promise<PgOpportuniteRow[]> {
try {
const result = await pgNoParallel(sql`
WITH famille AS (
SELECT DISTINCT ON (TRIM(a.codein::text))
a.no_id,
TRIM(a.codein::text) AS codein,
TRIM(a.artcentrale::text) AS code_centrale,
COALESCE(a.libelle1, '') AS libelle1
FROM articles a
WHERE a.nom_no_id = ${nomNoId}
AND a.codein IS NOT NULL
AND a.artcentrale IS NOT NULL
AND TRIM(a.artcentrale::text) <> ''
ORDER BY TRIM(a.codein::text), a.no_id DESC
),
avec_reseau AS (
SELECT
f.no_id,
f.codein,
f.code_centrale,
f.libelle1,
q.qte_reseau::float AS qte_reseau,
q.nb_magasins_reseau AS nb_magasins_reseau,
COALESCE(q.ca_reseau, 0)::float AS ca_reseau,
COALESCE(q.ca_par_magasin_reseau, 0)::float AS ca_par_magasin_reseau
FROM famille f
JOIN qlik_network_metrics q ON q.code_centrale = f.code_centrale
WHERE q.nb_magasins_reseau > 0
AND q.qte_reseau > 0
),
ventes AS (
SELECT
TRIM(a.codein::text) AS codein,
SUM(-m.qtemvt)::float AS qte_locale,
COUNT(DISTINCT m.site) AS nb_sites_locaux
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
WHERE m.genremvt = 3
AND m.site IN ('292', '579')
AND m.datmvt BETWEEN ${dateDebut}::date AND ${dateFin}::date
AND TRIM(a.codein::text) IN (SELECT codein FROM avec_reseau)
GROUP BY TRIM(a.codein::text)
)
SELECT
r.codein,
r.libelle1,
r.code_centrale,
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur,
r.qte_reseau,
r.nb_magasins_reseau,
r.ca_reseau,
r.ca_par_magasin_reseau,
COALESCE(v.qte_locale, 0)::float AS qte_locale,
COALESCE(v.nb_sites_locaux, 0) AS nb_sites_locaux,
(r.qte_reseau / r.nb_magasins_reseau)::float AS qte_reseau_par_magasin,
(COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))::float AS qte_locale_par_magasin,
(
(r.qte_reseau / r.nb_magasins_reseau)
- (COALESCE(v.qte_locale, 0) / GREATEST(COALESCE(v.nb_sites_locaux, 0), 1))
)::float AS ecart_par_magasin
FROM avec_reseau r
LEFT JOIN ventes v ON v.codein = r.codein
LEFT JOIN LATERAL (
SELECT af1.code
FROM artfou1 af1
WHERE af1.art_no_id = r.no_id AND af1.preference = 1
ORDER BY af1.code
LIMIT 1
) af ON TRUE
LEFT JOIN fouident fi ON fi.code = af.code
ORDER BY ecart_par_magasin DESC
LIMIT ${limit}
`);
console.log(`[pg-ff] pgGetOpportunitesFamille(nom=${nomNoId}): ${result.rows.length} lignes`);
return result.rows as unknown as PgOpportuniteRow[];
} catch (e) {
console.error("[pg-ff] pgGetOpportunitesFamille error:", (e as Error).message?.slice(0, 250));
return [];
}
}
+24
View File
@@ -91,6 +91,30 @@ export interface NetworkMetric {
periode?: string;
/** Quantité vendue réseau par mois : { "YYYY-MM": qté }. Rempli si sync avec filtre Date. */
qteByMonth?: Record<string, number>;
/**
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
*
* Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois) —
* les conserver ne coûte aucune requête supplémentaire. Seule `qte` est
* garantie : les mois N-1 dérivés de « Quantité COMP » n'ont que la quantité
* tant que la passe complémentaire N-1 ne les a pas complétés (elle est
* sautée si le champ Qlik `Année` est absent).
*/
metricsByMonth?: Record<string, QlikMonthMetrics>;
}
/** Les mesures réseau Qlik pour un produit sur un mois donné. */
export interface QlikMonthMetrics {
/** Quantité vendue réseau — toujours présente. */
qte: number;
/** CA réseau du mois. */
ca?: number;
/** Nombre de magasins ayant vendu le produit ce mois-là. */
nbMag?: number;
/** CA moyen par magasin sur le mois (mesure Qlik brute). */
caMag?: number;
/** Taux de marge réseau du mois (ratio brut Qlik, ex 0.32 = 32%). */
margePct?: number;
}
function makeXrfkey(): string {
+13 -1
View File
@@ -9,7 +9,7 @@ import "server-only";
import { db } from "@/db";
import { qlikNetworkMetrics } from "@/db/schema";
import { inArray, sql } from "drizzle-orm";
import type { NetworkMetric } from "@/lib/qlik-client";
import type { NetworkMetric, QlikMonthMetrics } from "@/lib/qlik-client";
export interface NetworkMetricCached {
caReseau: number;
@@ -19,6 +19,14 @@ export interface NetworkMetricCached {
margePctReseau: number;
/** Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. */
qteByMonth: Record<string, number> | null;
/**
* Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }.
* null pour les lignes synchronisées avant l'ajout de la colonne — l'UI
* retombe alors sur `qteByMonth`.
*/
metricsByMonth: Record<string, QlikMonthMetrics> | null;
/** Période couverte par l'extraction (ex "2025-03_2026-02"). */
periode: string | null;
fetchedAt: string | null;
}
@@ -43,6 +51,8 @@ export async function getNetworkMetricsByCodeCentrale(
caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0,
margePctReseau: Number(r.margePctReseau ?? 0) || 0,
qteByMonth: (r.qteByMonth as Record<string, number> | null) ?? null,
metricsByMonth: (r.metricsByMonth as Record<string, QlikMonthMetrics> | null) ?? null,
periode: r.periode ?? null,
fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null,
});
}
@@ -62,6 +72,7 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
margePctReseau: String(m.margePctReseau),
periode: m.periode ?? null,
qteByMonth: m.qteByMonth ?? null,
metricsByMonth: m.metricsByMonth ?? null,
fetchedAt: now,
}));
@@ -83,6 +94,7 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
margePctReseau: sql`excluded.marge_pct_reseau`,
periode: sql`excluded.periode`,
qteByMonth: sql`excluded.qte_by_month`,
metricsByMonth: sql`excluded.metrics_by_month`,
fetchedAt: sql`excluded.fetched_at`,
},
});
+40 -10
View File
@@ -34,8 +34,16 @@ async function getBrowser(): Promise<Browser> {
interface InPageResult {
ok: boolean;
rows?: Array<Array<string | number>>;
/** Quantité réseau par code central et par mois : { code: { "YYYY-MM": qté } }. */
monthly?: Record<string, Record<string, number>>;
/**
* Métriques réseau par code central et par mois : { code: { "YYYY-MM": {…} } }.
*
* Le cube mensuel renvoie déjà les 6 mesures par (Article Code, Mois) ; on les
* conserve toutes. Seule `qte` est systématiquement présente : les mois N-1
* dérivés de « Quantité COMP » n'ont que la quantité (COMP ne porte pas les
* autres mesures), et la passe complémentaire N-1 les complète quand le champ
* `Année` existe.
*/
monthly?: Record<string, Record<string, { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number }>>;
size?: number;
error?: string;
diag?: Record<string, unknown>;
@@ -296,14 +304,21 @@ export async function fetchNetworkMetricsPlaywright(
const out: Array<Array<string | number>> = [];
// Quantité réseau par code central et par mois (label "YYYY-MM").
// Accumulée en parallèle du total, sans toucher l'agrégation existante.
const monthlyByCode: Record<string, Record<string, number>> = {};
const monthlyByCode: Record<string, Record<string, { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number }>> = {};
const accMonthly = (batchRows: Array<Array<string | number>>, monthLabel: string) => {
for (const r of batchRows) {
const c = String(r[0] ?? "").trim();
if (!c || c === "-") continue;
const q = Number(r[2]) || 0;
(monthlyByCode[c] ??= {});
monthlyByCode[c][monthLabel] = (monthlyByCode[c][monthLabel] ?? 0) + q;
const cur = monthlyByCode[c][monthLabel];
monthlyByCode[c][monthLabel] = {
qte: (cur?.qte ?? 0) + q,
ca: (cur?.ca ?? 0) + (Number(r[1]) || 0),
nbMag: Math.max(cur?.nbMag ?? 0, Number(r[3]) || 0),
caMag: Number(r[4]) || 0,
margePct: Number(r[5]) || 0,
};
}
};
// Compteurs agrégés pour le résumé timing final (loggué côté Node après la sync).
@@ -485,14 +500,19 @@ export async function fetchNetworkMetricsPlaywright(
}));
}
(monthlyByCode[code] ??= {});
// Les 5 mesures du mois courant de la ligne. Le cube les renvoie
// déjà toutes : les conserver ne coûte aucune requête Qlik en plus.
const full = { qte, ca, nbMag, caMag, margePct: marge };
if (isMainPass && y === yearN) {
// Mois de l'année N + son comparable N-1 (COMP = même mois, N-1).
monthlyByCode[code][mm] = qte;
monthlyByCode[code][mm] = full;
const mmPrev = String(y - 1) + mm.slice(4); // "2026-03" → "2025-03"
if (monthlyByCode[code][mmPrev] === undefined) monthlyByCode[code][mmPrev] = qteComp;
// COMP ne porte QUE la quantité : les autres mesures restent
// absentes tant que la passe N-1 ne les a pas complétées.
if (monthlyByCode[code][mmPrev] === undefined) monthlyByCode[code][mmPrev] = { qte: qteComp };
} else {
// Passe année sélectionnée : valeur directe, prioritaire sur COMP.
monthlyByCode[code][mm] = qte;
// Passe année sélectionnée : valeurs directes, prioritaires sur COMP.
monthlyByCode[code][mm] = full;
}
}, timings, () => { code15Retries++; }), timings);
aggBatches++;
@@ -826,11 +846,21 @@ export async function fetchNetworkMetricsPlaywright(
prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0;
margeWeight.set(code, nextWeight);
}
// Rattache la quantité mensuelle (par code central) au métrique agrégé.
// Rattache le détail mensuel (par code central) au métrique agrégé.
// On produit DEUX représentations :
// - `qteByMonth` : { "YYYY-MM": qté } — format historique, dont dépend la
// colonne « Tendance / Réseau » de la Grille. Inchangé.
// - `metricsByMonth` : toutes les mesures du mois, pour la fiche produit.
const monthly = result.monthly ?? {};
for (const [code, metric] of out) {
const mm = monthly[code];
if (mm && Object.keys(mm).length) metric.qteByMonth = mm;
if (!mm || Object.keys(mm).length === 0) continue;
const qteByMonth: Record<string, number> = {};
for (const [mois, v] of Object.entries(mm)) {
qteByMonth[mois] = Number(v?.qte) || 0;
}
metric.qteByMonth = qteByMonth;
metric.metricsByMonth = mm;
}
// Diagnostic CONCLUSIF : combien de codes ont des mois qui VARIENT vs identiques.
// Si presque tous varient → la mesure respecte le mois (feature OK).