mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
Merge: recherche produit + fiche 360° local/réseau
This commit is contained in:
25 files changed
+2694
-465
No files matched your search
+2
-1
@@ -113,7 +113,8 @@ async function main() {
|
|||||||
ALTER TABLE "qlik_network_metrics"
|
ALTER TABLE "qlik_network_metrics"
|
||||||
ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2),
|
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 "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 "rupture_pct_reseau";
|
||||||
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau";
|
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau";
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -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'a pas de code centrale : il n'est pas référencé centralement et n'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'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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'a de données réseau en cache.
|
||||||
|
Synchronisez d'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+173
-19
@@ -26,7 +26,10 @@ type QlikSyncStatus = "idle" | "running" | "success" | "error";
|
|||||||
|
|
||||||
interface QlikSyncJob {
|
interface QlikSyncJob {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
|
/** Code fournisseur (mode fournisseur) — chaîne vide en mode produit. */
|
||||||
fournisseur: string;
|
fournisseur: string;
|
||||||
|
/** Code centrale (mode produit) — absent en mode fournisseur. */
|
||||||
|
codeCentrale?: string;
|
||||||
status: QlikSyncStatus;
|
status: QlikSyncStatus;
|
||||||
requested: number;
|
requested: number;
|
||||||
fetched: number;
|
fetched: number;
|
||||||
@@ -48,6 +51,33 @@ interface QlikSyncJob {
|
|||||||
*/
|
*/
|
||||||
const jobs = new Map<string, 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. */
|
/** Construit un jobId court et lisible. */
|
||||||
function newJobId(): string {
|
function newJobId(): string {
|
||||||
return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
@@ -58,6 +88,7 @@ function publicJob(job: QlikSyncJob) {
|
|||||||
return {
|
return {
|
||||||
jobId: job.jobId,
|
jobId: job.jobId,
|
||||||
fournisseur: job.fournisseur,
|
fournisseur: job.fournisseur,
|
||||||
|
codeCentrale: job.codeCentrale,
|
||||||
status: job.status,
|
status: job.status,
|
||||||
requested: job.requested,
|
requested: job.requested,
|
||||||
fetched: job.fetched,
|
fetched: job.fetched,
|
||||||
@@ -72,11 +103,12 @@ function publicJob(job: QlikSyncJob) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** État "idle" sérialisé (pas de job connu pour ce fournisseur). */
|
/** État "idle" sérialisé (pas de job connu pour cette cible). */
|
||||||
function idleJob(fournisseur: string) {
|
function idleJob(fournisseur: string, codeCentrale?: string) {
|
||||||
return {
|
return {
|
||||||
jobId: null,
|
jobId: null,
|
||||||
fournisseur,
|
fournisseur,
|
||||||
|
codeCentrale,
|
||||||
status: "idle" as const,
|
status: "idle" as const,
|
||||||
requested: 0,
|
requested: 0,
|
||||||
fetched: 0,
|
fetched: 0,
|
||||||
@@ -105,6 +137,20 @@ async function requireAdmin(): Promise<NextResponse | null> {
|
|||||||
return 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".
|
* Regex de validation d'un code centrale "raisonnable".
|
||||||
*
|
*
|
||||||
@@ -209,34 +255,95 @@ async function runJob(job: QlikSyncJob): Promise<void> {
|
|||||||
job.status = "success";
|
job.status = "success";
|
||||||
job.finishedAt = new Date().toISOString();
|
job.finishedAt = new Date().toISOString();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
applyJobError(job, 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).";
|
|
||||||
} else {
|
|
||||||
job.error = msg;
|
|
||||||
}
|
|
||||||
job.finishedAt = new Date().toISOString();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/qlik/sync?fournisseur=XXX
|
* Inscrit une erreur dans le job, en traduisant la saturation mémoire du
|
||||||
* Retourne l'état courant du job pour ce fournisseur. Si aucun job n'est
|
* 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";
|
||||||
|
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).";
|
||||||
|
} else {
|
||||||
|
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 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
|
* 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.
|
* frontend de reprendre l'état après un reload et de poller en arrière-plan.
|
||||||
*/
|
*/
|
||||||
export async function GET(req: NextRequest) {
|
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();
|
const denied = await requireAdmin();
|
||||||
if (denied) return denied;
|
if (denied) return denied;
|
||||||
|
|
||||||
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
|
||||||
if (!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);
|
const job = jobs.get(fournisseur);
|
||||||
if (!job) {
|
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.
|
* ensuite poller GET pour suivre l'avancement et être notifié de la fin.
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
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();
|
const denied = await requireAdmin();
|
||||||
if (denied) return denied;
|
if (denied) return denied;
|
||||||
|
|
||||||
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||||
if (!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);
|
const existing = jobs.get(fournisseur);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -11,6 +11,7 @@ import { useSession, signOut } from "next-auth/react";
|
|||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ icon: BarChart2, label: "Dashboard", href: "/dashboard" },
|
{ icon: BarChart2, label: "Dashboard", href: "/dashboard" },
|
||||||
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
|
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
|
||||||
|
{ icon: PackageSearch, label: "Produits", href: "/produits" },
|
||||||
{ icon: TrendingUp, label: "Analytics", href: "/analytics" },
|
{ icon: TrendingUp, label: "Analytics", href: "/analytics" },
|
||||||
{ icon: Trophy, label: "Hit Parade", href: "/hit-parade" },
|
{ icon: Trophy, label: "Hit Parade", href: "/hit-parade" },
|
||||||
{ icon: Megaphone, label: "Publicités", href: "/publicites" },
|
{ icon: Megaphone, label: "Publicités", href: "/publicites" },
|
||||||
|
|||||||
@@ -125,6 +125,12 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
|
|||||||
periode: varchar("periode", { length: 20 }),
|
periode: varchar("periode", { length: 20 }),
|
||||||
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (12 derniers mois). */
|
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (12 derniers mois). */
|
||||||
qteByMonth: jsonb("qte_by_month"),
|
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 */
|
/** Dernière synchro depuis Qlik */
|
||||||
fetchedAt: timestamp("fetched_at").defaultNow(),
|
fetchedAt: timestamp("fetched_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ import { db } from "@/db";
|
|||||||
import { sessionSnapshots } from "@/db/schema";
|
import { sessionSnapshots } from "@/db/schema";
|
||||||
import { eq, desc } from "drizzle-orm";
|
import { eq, desc } from "drizzle-orm";
|
||||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||||
|
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
|
||||||
const NB_MAGASINS_RESEAU = 270;
|
|
||||||
|
|
||||||
interface GetProductRowsInput {
|
interface GetProductRowsInput {
|
||||||
codeFournisseur: string;
|
codeFournisseur: string;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
RowSelectionState,
|
RowSelectionState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
@@ -26,64 +26,19 @@ import { GammeSelect } from "@/features/grid/components/gamme-select";
|
|||||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
||||||
import type { ProductRow, GammeCode } from "@/types/grid";
|
import type { ProductRow, GammeCode } from "@/types/grid";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
function getLast12Months(): string[] {
|
getLast12Months,
|
||||||
const months: string[] = [];
|
formatMonthLabel,
|
||||||
const now = new Date();
|
formatDate,
|
||||||
// On commence à i=12 (il y a 12 mois) et on finit à i=1 (le mois dernier)
|
SITE_LABELS,
|
||||||
// pour exclure le mois en cours (i=0)
|
getStoreConfig,
|
||||||
for (let i = 12; i >= 1; i--) {
|
} from "@/features/grid/lib/months";
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
import {
|
||||||
months.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
|
computeNetworkTrend,
|
||||||
}
|
trendLabel,
|
||||||
return months;
|
TREND_COLOR,
|
||||||
}
|
} from "@/features/grid/lib/network-trend";
|
||||||
|
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HeatmapGridProps {
|
interface HeatmapGridProps {
|
||||||
onSelectionChange?: (codeins: string[]) => void;
|
onSelectionChange?: (codeins: string[]) => void;
|
||||||
@@ -238,135 +193,6 @@ interface CellDetailData {
|
|||||||
receptions: number | null;
|
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. */
|
/** Modal : quantités vendues réseau sur les 12 derniers mois, en une seule courbe. */
|
||||||
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
|
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
|
||||||
const trend = computeNetworkTrend(row.qteReseauByMonth);
|
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></>}
|
{row.nbMagasinsReseau != null && <> · <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{row.nbMagasinsReseau} magasins</span></>}
|
||||||
{pct != null && (
|
{pct != null && (
|
||||||
<> · <span className="font-bold" style={{ color }}>
|
<> · <span className="font-bold" style={{ color }}>
|
||||||
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% ·{" "}
|
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)}
|
||||||
{pct > TREND_STRONG ? "Forte hausse" : pct > 0.08 ? "Hausse" : pct < -TREND_STRONG ? "Forte baisse" : pct < -0.08 ? "Baisse" : "Stable"}
|
|
||||||
</span></>
|
</span></>
|
||||||
)}
|
)}
|
||||||
</p>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback } from "react";
|
||||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
|
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
|
||||||
|
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
|
||||||
|
|
||||||
interface SyncQlikButtonProps {
|
interface SyncQlikButtonProps {
|
||||||
/** Code fournisseur affiché (pour sync ciblée) */
|
/** Code fournisseur affiché (pour sync ciblée) */
|
||||||
@@ -11,24 +12,8 @@ interface SyncQlikButtonProps {
|
|||||||
lastUpdate?: string | null;
|
lastUpdate?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** État serveur renvoyé par GET/POST /api/qlik/sync */
|
/** Date + heure de dernière synchro, ou "jamais". */
|
||||||
interface SyncJobState {
|
function formatLastUpdate(iso?: string | null): string {
|
||||||
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 {
|
|
||||||
if (!iso) return "jamais";
|
if (!iso) return "jamais";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (isNaN(d.getTime())) return "jamais";
|
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é
|
* 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.
|
* (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.
|
* Toute la mécanique de job (POST puis polling GET, reprise après reload) vit
|
||||||
* Le composant poll ensuite GET pour suivre l'avancement. Au mount, on appelle
|
* dans `useQlikSyncJob`, partagée avec la fiche produit.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
|
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
|
||||||
const [status, setStatus] = useState<"idle" | "running" | "success" | "error">("idle");
|
|
||||||
const [message, setMessage] = useState<string>("");
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
/** Force le refresh de la grille (avec cache-buster) et de la barre de résumé. */
|
||||||
/** 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.
|
|
||||||
*/
|
|
||||||
const refreshGrid = useCallback(() => {
|
const refreshGrid = useCallback(() => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set("_refresh", String(Date.now()));
|
params.set("_refresh", String(Date.now()));
|
||||||
@@ -95,163 +40,10 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
}, [pathname, router, searchParams]);
|
}, [pathname, router, searchParams]);
|
||||||
|
|
||||||
/**
|
const { status, message, start } = useQlikSyncJob({
|
||||||
* Polling GET pendant qu'un job tourne. S'arrête tout seul quand le job
|
target: { mode: "fournisseur", fournisseur: codeFournisseur },
|
||||||
* passe en success/error, ou après un timeout de sécurité.
|
onSuccess: refreshGrid,
|
||||||
*/
|
});
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -264,13 +56,13 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
|
|||||||
? message
|
? message
|
||||||
: status === "running"
|
: status === "running"
|
||||||
? (message || `Extraction Qlik…`)
|
? (message || `Extraction Qlik…`)
|
||||||
: `MAJ Qlik · ${formatDate(lastUpdate)}`}
|
: `MAJ Qlik · ${formatLastUpdate(lastUpdate)}`}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleSync}
|
onClick={start}
|
||||||
disabled={status === "running"}
|
disabled={status === "running"}
|
||||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
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" ? (
|
{status === "running" ? (
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
+527
-19
@@ -273,26 +273,37 @@ export interface PgNomRow {
|
|||||||
* Colonnes confirmées : nomenclature.code, nomenclature.libelle
|
* Colonnes confirmées : nomenclature.code, nomenclature.libelle
|
||||||
* Colonne parent : découverte dynamiquement via information_schema
|
* Colonne parent : découverte dynamiquement via information_schema
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
`);
|
||||||
|
const nomCols = (metaResult.rows as { column_name: string; data_type: string }[]);
|
||||||
|
console.log("[pg-ff] Nomenclature colonnes:", nomCols.map(r => `${r.column_name}(${r.data_type})`).join(", "));
|
||||||
|
// Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent)
|
||||||
|
// "chemin_pere" = chemin vers le père en français
|
||||||
|
// On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé)
|
||||||
|
const parentRow = nomCols.find(r =>
|
||||||
|
(/parent|pere|father/i.test(r.column_name) || (r.column_name !== "no_id" && /no_id$/i.test(r.column_name)))
|
||||||
|
&& /int|serial/i.test(r.data_type)
|
||||||
|
);
|
||||||
|
_nomenclatureParentCol = parentRow?.column_name ?? null;
|
||||||
|
console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol);
|
||||||
|
return _nomenclatureParentCol;
|
||||||
|
}
|
||||||
|
|
||||||
export async function pgGetNomenclatureByFournisseur(codefou: string): Promise<Map<string, PgNomRow>> {
|
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)
|
const parentCol = await getNomenclatureParentCol();
|
||||||
if (_nomenclatureParentCol === undefined) {
|
|
||||||
const metaResult = await db.execute(sql`
|
|
||||||
SELECT column_name, data_type FROM information_schema.columns
|
|
||||||
WHERE table_name = 'nomenclature' ORDER BY ordinal_position
|
|
||||||
`);
|
|
||||||
const nomCols = (metaResult.rows as { column_name: string; data_type: string }[]);
|
|
||||||
console.log("[pg-ff] Nomenclature colonnes:", nomCols.map(r => `${r.column_name}(${r.data_type})`).join(", "));
|
|
||||||
// Cherche une colonne "parent" qui soit un entier (FK vers no_id du parent)
|
|
||||||
// "chemin_pere" = chemin vers le père en français
|
|
||||||
// On vérifie le data_type : integer/bigint/smallint uniquement (pas text/varchar qui serait un chemin matérialisé)
|
|
||||||
const parentRow = nomCols.find(r =>
|
|
||||||
(/parent|pere|father/i.test(r.column_name) || (r.column_name !== "no_id" && /no_id$/i.test(r.column_name)))
|
|
||||||
&& /int|serial/i.test(r.data_type)
|
|
||||||
);
|
|
||||||
_nomenclatureParentCol = parentRow?.column_name ?? null;
|
|
||||||
console.log("[pg-ff] Nomenclature parentCol:", _nomenclatureParentCol);
|
|
||||||
}
|
|
||||||
const parentCol = _nomenclatureParentCol;
|
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
if (parentCol) {
|
if (parentCol) {
|
||||||
@@ -1226,3 +1237,500 @@ export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string
|
|||||||
return map;
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,30 @@ export interface NetworkMetric {
|
|||||||
periode?: string;
|
periode?: string;
|
||||||
/** Quantité vendue réseau par mois : { "YYYY-MM": qté }. Rempli si sync avec filtre Date. */
|
/** Quantité vendue réseau par mois : { "YYYY-MM": qté }. Rempli si sync avec filtre Date. */
|
||||||
qteByMonth?: Record<string, number>;
|
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 {
|
function makeXrfkey(): string {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import "server-only";
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { qlikNetworkMetrics } from "@/db/schema";
|
import { qlikNetworkMetrics } from "@/db/schema";
|
||||||
import { inArray, sql } from "drizzle-orm";
|
import { inArray, sql } from "drizzle-orm";
|
||||||
import type { NetworkMetric } from "@/lib/qlik-client";
|
import type { NetworkMetric, QlikMonthMetrics } from "@/lib/qlik-client";
|
||||||
|
|
||||||
export interface NetworkMetricCached {
|
export interface NetworkMetricCached {
|
||||||
caReseau: number;
|
caReseau: number;
|
||||||
@@ -19,6 +19,14 @@ export interface NetworkMetricCached {
|
|||||||
margePctReseau: number;
|
margePctReseau: number;
|
||||||
/** Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. */
|
/** Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. */
|
||||||
qteByMonth: Record<string, number> | null;
|
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;
|
fetchedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +51,8 @@ export async function getNetworkMetricsByCodeCentrale(
|
|||||||
caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0,
|
caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0,
|
||||||
margePctReseau: Number(r.margePctReseau ?? 0) || 0,
|
margePctReseau: Number(r.margePctReseau ?? 0) || 0,
|
||||||
qteByMonth: (r.qteByMonth as Record<string, number> | null) ?? null,
|
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,
|
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),
|
margePctReseau: String(m.margePctReseau),
|
||||||
periode: m.periode ?? null,
|
periode: m.periode ?? null,
|
||||||
qteByMonth: m.qteByMonth ?? null,
|
qteByMonth: m.qteByMonth ?? null,
|
||||||
|
metricsByMonth: m.metricsByMonth ?? null,
|
||||||
fetchedAt: now,
|
fetchedAt: now,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -83,6 +94,7 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise<nu
|
|||||||
margePctReseau: sql`excluded.marge_pct_reseau`,
|
margePctReseau: sql`excluded.marge_pct_reseau`,
|
||||||
periode: sql`excluded.periode`,
|
periode: sql`excluded.periode`,
|
||||||
qteByMonth: sql`excluded.qte_by_month`,
|
qteByMonth: sql`excluded.qte_by_month`,
|
||||||
|
metricsByMonth: sql`excluded.metrics_by_month`,
|
||||||
fetchedAt: sql`excluded.fetched_at`,
|
fetchedAt: sql`excluded.fetched_at`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+40
-10
@@ -34,8 +34,16 @@ async function getBrowser(): Promise<Browser> {
|
|||||||
interface InPageResult {
|
interface InPageResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
rows?: Array<Array<string | number>>;
|
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;
|
size?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
diag?: Record<string, unknown>;
|
diag?: Record<string, unknown>;
|
||||||
@@ -296,14 +304,21 @@ export async function fetchNetworkMetricsPlaywright(
|
|||||||
const out: Array<Array<string | number>> = [];
|
const out: Array<Array<string | number>> = [];
|
||||||
// Quantité réseau par code central et par mois (label "YYYY-MM").
|
// 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.
|
// 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) => {
|
const accMonthly = (batchRows: Array<Array<string | number>>, monthLabel: string) => {
|
||||||
for (const r of batchRows) {
|
for (const r of batchRows) {
|
||||||
const c = String(r[0] ?? "").trim();
|
const c = String(r[0] ?? "").trim();
|
||||||
if (!c || c === "-") continue;
|
if (!c || c === "-") continue;
|
||||||
const q = Number(r[2]) || 0;
|
const q = Number(r[2]) || 0;
|
||||||
(monthlyByCode[c] ??= {});
|
(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).
|
// 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] ??= {});
|
(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) {
|
if (isMainPass && y === yearN) {
|
||||||
// Mois de l'année N + son comparable N-1 (COMP = même mois, N-1).
|
// 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"
|
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 {
|
} else {
|
||||||
// Passe année sélectionnée : valeur directe, prioritaire sur COMP.
|
// Passe année sélectionnée : valeurs directes, prioritaires sur COMP.
|
||||||
monthlyByCode[code][mm] = qte;
|
monthlyByCode[code][mm] = full;
|
||||||
}
|
}
|
||||||
}, timings, () => { code15Retries++; }), timings);
|
}, timings, () => { code15Retries++; }), timings);
|
||||||
aggBatches++;
|
aggBatches++;
|
||||||
@@ -826,11 +846,21 @@ export async function fetchNetworkMetricsPlaywright(
|
|||||||
prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0;
|
prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0;
|
||||||
margeWeight.set(code, nextWeight);
|
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 ?? {};
|
const monthly = result.monthly ?? {};
|
||||||
for (const [code, metric] of out) {
|
for (const [code, metric] of out) {
|
||||||
const mm = monthly[code];
|
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.
|
// Diagnostic CONCLUSIF : combien de codes ont des mois qui VARIENT vs identiques.
|
||||||
// Si presque tous varient → la mesure respecte le mois (feature OK).
|
// Si presque tous varient → la mesure respecte le mois (feature OK).
|
||||||
|
|||||||
Reference in new issue
Block a user