perf(stock): onglets instantanés, pagination et fournisseur de dernière entrée

Le changement d'onglet déclenchait un router.push : la page serveur était
rejouée et les 3 requêtes SQL relancées à chaque clic, alors que les données
étaient déjà côté client. Sans aucun retour visuel, l'onglet semblait bloqué.

Interface (stock-negatif/client.tsx)
- Changement d'onglet purement client (useTransition + history.replaceState) :
  plus aucune requête SQL rejouée, l'URL reste partageable.
- Modal de chargement pendant le changement de magasin (seule action qui
  interroge vraiment le serveur), pendant un changement d'onglet lent et
  pendant la génération de l'export Excel. Affichage différé de 120 ms pour
  éviter tout clignotement.
- Pagination 50 lignes : l'onglet « Sans vente 6 mois » rendait jusqu'à 17 000
  lignes d'un coup, ce qui figeait le navigateur. L'export Excel continue de
  reprendre l'intégralité des lignes filtrées.
- Tri : ordre de la base conservé tant qu'aucune colonne n'est cliquée (le tri
  alphabétique sur codein écrasait le classement métier), collateur Intl
  réutilisé au lieu d'un localeCompare par comparaison, tri numérique robuste
  aux valeurs nulles.
- Les 3 onglets partagent désormais un composant de tableau unique piloté par
  une configuration de colonnes, au lieu de 3 copies quasi identiques.
- Select magasin et onglets désactivés pendant le chargement, aria-busy posé.

Données (pg-ff-client.ts)
- Un article n'est plus rattaché à son fournisseur principal mais au
  fournisseur de sa dernière entrée en stock : s'il est rentré chez un autre
  fournisseur, il n'apparaît plus sous le précédent. La colonne de mvtart qui
  porte ce lien est détectée une fois par process parmi une liste blanche
  (même principe que getNomenclatureParentCol), avec repli documenté sur
  artfou1.preference = 1 si la base ne porte pas l'information.
- La jointure latérale sur la dernière entrée remplace la sous-requête
  corrélée MAX(datmvt) : même coût qu'avant pour une information de plus.
- pgGetStockSansVente dédoublonne artfou1 via DISTINCT ON comme ses deux
  requêtes sœurs, et exclut les stocks nuls en SQL (HAVING) au lieu de les
  filtrer côté client : le compteur d'onglet correspond enfin aux lignes
  réellement transmises.

Diagnostic
- GET /api/diag/stock-fournisseur : colonne détectée, colonnes réelles de
  mvtart et échantillon comparant fournisseur principal et fournisseur de
  dernière entrée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174nm1nWPaypWBNHioSTJ8X
This commit is contained in:
Claude committed 2026-09-22 10:29:00 +00:00
1 parent 585e171708
commit 2c84cfda53
3 files changed
+638 -423

No files matched your search

+460 -400
View File
@@ -1,12 +1,29 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect, useTransition, type ReactNode } from "react";
import { ChevronUp, ChevronDown, ChevronsUpDown, Download, Search } from "lucide-react"; import {
ChevronUp,
ChevronDown,
ChevronsUpDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Download,
Search,
} from "lucide-react";
import LoadingModal from "@/components/shared/loading-modal";
import type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow } from "./page"; import type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow } from "./page";
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
/** Nombre de lignes rendues simultanément : au-delà, le navigateur fige. */
const PAGE_SIZE = 50;
/** Un seul collateur réutilisé : `localeCompare` en crée un par appel (très coûteux sur 17 000 lignes). */
const collator = new Intl.Collator("fr", { numeric: true, sensitivity: "base" });
function fmtDate(raw: string | null) { function fmtDate(raw: string | null) {
if (!raw) return "—"; if (!raw) return "—";
const d = new Date(raw); const d = new Date(raw);
@@ -21,9 +38,66 @@ function getYear(raw: string | null): string {
return String(d.getFullYear()); return String(d.getFullYear());
} }
function SortIcon<T extends string>({ col, sortKey, sortDir }: { col: T; sortKey: T; sortDir: SortDir }) { /**
if (col !== sortKey) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />; * Passe à `true` seulement si `active` reste vrai plus de `delay` ms.
return sortDir === "asc" * Évite le clignotement du modal sur les transitions instantanées.
*/
function useDelayedFlag(active: boolean, delay = 120) {
const [elapsed, setElapsed] = useState(false);
const [wasActive, setWasActive] = useState(active);
// Réinitialisation pendant le rendu (et non dans un effet) : dès que
// l'attente cesse, le prochain délai repart de zéro.
if (wasActive !== active) {
setWasActive(active);
if (!active) setElapsed(false);
}
useEffect(() => {
if (!active) return;
const id = setTimeout(() => setElapsed(true), delay);
return () => clearTimeout(id);
}, [active, delay]);
return active && elapsed;
}
// ---------------------------------------------------------------------------
// Tableau générique (les 3 onglets partagent filtres, tri, pagination, export)
// ---------------------------------------------------------------------------
interface Column<T> {
key: Extract<keyof T, string>;
label: string;
/** Tri numérique au lieu du tri alphabétique. */
numeric?: boolean;
render?: (row: T) => ReactNode;
}
interface ExcelSpec<T> {
sheet: string;
filePrefix: string;
header: string[];
widths: number[];
row: (r: T) => (string | number)[];
}
interface StockTableProps<T> {
rows: T[];
magasin: string;
columns: Column<T>[];
fournisseurOf: (r: T) => string;
/** Source du filtre « Année » — omis, le filtre n'est pas affiché. */
anneeOf?: (r: T) => string;
anneeLabel?: string;
searchIn: (r: T) => (string | null | undefined)[];
emptyLabel: string;
excel: ExcelSpec<T>;
}
function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) {
if (!active) return <ChevronsUpDown className="inline w-3 h-3 ml-1 opacity-30" />;
return dir === "asc"
? <ChevronUp className="inline w-3 h-3 ml-1" /> ? <ChevronUp className="inline w-3 h-3 ml-1" />
: <ChevronDown className="inline w-3 h-3 ml-1" />; : <ChevronDown className="inline w-3 h-3 ml-1" />;
} }
@@ -49,95 +123,174 @@ function FilterSelect({ label, value, onChange, options }: {
); );
} }
function useSortedRows<T extends Record<string, unknown>>(rows: T[], numericKeys: string[]) { function Pagination({ page, totalPages, total, from, to, onChange }: {
const [sortKey, setSortKey] = useState<keyof T>(Object.keys(rows[0] ?? { codein: "" })[0] as keyof T); page: number;
const [sortDir, setSortDir] = useState<SortDir>("asc"); totalPages: number;
total: number;
from: number;
to: number;
onChange: (p: number) => void;
}) {
if (total === 0) return null;
function handleSort(key: keyof T) { const btn = "rounded-lg border border-gray-200 bg-white p-1.5 text-gray-600 shadow-sm transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40";
if (sortKey === key) setSortDir(d => d === "asc" ? "desc" : "asc");
return (
<div className="flex flex-wrap items-center justify-between gap-3 px-1">
<span className="text-xs text-gray-500">
Lignes <span className="font-medium text-gray-700">{from.toLocaleString("fr-FR")}</span>
{" à "}
<span className="font-medium text-gray-700">{to.toLocaleString("fr-FR")}</span>
{" sur "}
<span className="font-medium text-gray-700">{total.toLocaleString("fr-FR")}</span>
</span>
<div className="flex items-center gap-1.5">
<button className={btn} onClick={() => onChange(1)} disabled={page <= 1} aria-label="Première page">
<ChevronsLeft className="w-4 h-4" />
</button>
<button className={btn} onClick={() => onChange(page - 1)} disabled={page <= 1} aria-label="Page précédente">
<ChevronLeft className="w-4 h-4" />
</button>
<span className="px-2 text-xs text-gray-600 tabular-nums">
Page {page.toLocaleString("fr-FR")} / {totalPages.toLocaleString("fr-FR")}
</span>
<button className={btn} onClick={() => onChange(page + 1)} disabled={page >= totalPages} aria-label="Page suivante">
<ChevronRight className="w-4 h-4" />
</button>
<button className={btn} onClick={() => onChange(totalPages)} disabled={page >= totalPages} aria-label="Dernière page">
<ChevronsRight className="w-4 h-4" />
</button>
</div>
</div>
);
}
function StockTable<T extends object>({
rows,
magasin,
columns,
fournisseurOf,
anneeOf,
anneeLabel = "Année entrée",
searchIn,
emptyLabel,
excel,
}: StockTableProps<T>) {
const [filterFournisseur, setFilterFournisseur] = useState("");
const [filterAnnee, setFilterAnnee] = useState("");
const [search, setSearch] = useState("");
// `null` = ordre renvoyé par la base (le plus pertinent : stock le plus négatif,
// entrée la plus récente…). Aucun tri client tant que l'utilisateur n'en demande pas.
const [sortKey, setSortKey] = useState<Extract<keyof T, string> | null>(null);
const [sortDir, setSortDir] = useState<SortDir>("asc");
const [page, setPage] = useState(1);
const [exporting, setExporting] = useState(false);
const fournisseurs = useMemo(
() => [...new Set(rows.map(fournisseurOf).filter(Boolean))].sort((a, b) => collator.compare(a, b)),
[rows, fournisseurOf]
);
const annees = useMemo(
() => anneeOf ? [...new Set(rows.map(anneeOf).filter(Boolean))].sort((a, b) => b.localeCompare(a)) : [],
[rows, anneeOf]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!filterFournisseur && !filterAnnee && !q) return rows;
return rows.filter(r =>
(!filterFournisseur || fournisseurOf(r) === filterFournisseur) &&
(!filterAnnee || (anneeOf ? anneeOf(r) === filterAnnee : true)) &&
(!q || searchIn(r).some(v => v?.toLowerCase().includes(q)))
);
}, [rows, filterFournisseur, filterAnnee, search, fournisseurOf, anneeOf, searchIn]);
const numericKeys = useMemo(
() => new Set(columns.filter(c => c.numeric).map(c => c.key as string)),
[columns]
);
const sorted = useMemo(() => {
if (!sortKey) return filtered;
const numeric = numericKeys.has(sortKey);
const dir = sortDir === "asc" ? 1 : -1;
return [...filtered].sort((a, b) => {
const av = (a as Record<string, unknown>)[sortKey];
const bv = (b as Record<string, unknown>)[sortKey];
if (numeric) {
const an = Number(av);
const bn = Number(bv);
if (Number.isNaN(an) && Number.isNaN(bn)) return 0;
if (Number.isNaN(an)) return 1;
if (Number.isNaN(bn)) return -1;
return (an - bn) * dir;
}
return collator.compare(String(av ?? ""), String(bv ?? "")) * dir;
});
}, [filtered, sortKey, sortDir, numericKeys]);
// Toute modification des filtres / du tri ramène à la première page.
// Ajustement pendant le rendu : pas d'effet, donc pas de rendu en cascade.
const [pagedSet, setPagedSet] = useState(sorted);
if (pagedSet !== sorted) {
setPagedSet(sorted);
setPage(1);
}
const total = sorted.length;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const currentPage = Math.min(page, totalPages);
const pageRows = useMemo(
() => sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
[sorted, currentPage]
);
function handleSort(key: Extract<keyof T, string>) {
if (sortKey === key) setSortDir(d => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); } else { setSortKey(key); setSortDir("asc"); }
} }
const sorted = useMemo(() => [...rows].sort((a, b) => { const hasFilters = Boolean(filterFournisseur || filterAnnee || search);
const av = a[sortKey];
const bv = b[sortKey]; async function handleExport() {
if (numericKeys.includes(sortKey as string)) { if (exporting) return;
return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av); setExporting(true);
try {
const ExcelJS = (await import("exceljs")).default;
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet(excel.sheet);
ws.addRow(excel.header);
ws.getRow(1).eachCell(cell => {
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
cell.alignment = { vertical: "middle", horizontal: "center" };
});
ws.getRow(1).height = 22;
// L'export reprend l'intégralité des lignes filtrées, pas seulement la page affichée.
for (const r of sorted) ws.addRow(excel.row(r));
ws.columns = excel.widths.map(width => ({ width }));
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${excel.filePrefix}_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} finally {
setExporting(false);
} }
const as = String(av ?? "").toLowerCase();
const bs = String(bv ?? "").toLowerCase();
return sortDir === "asc" ? as.localeCompare(bs) : bs.localeCompare(as);
}), [rows, sortKey, sortDir]);
return { sorted, sortKey, sortDir, handleSort };
}
// ---------------------------------------------------------------------------
// Onglet 1 — Stock Négatif
// ---------------------------------------------------------------------------
function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin: string }) {
type SK = keyof PgStockNegatifRow;
const [filterFournisseur, setFilterFournisseur] = useState("");
const [filterAnnee, setFilterAnnee] = useState("");
const [search, setSearch] = useState("");
const fournisseurs = useMemo(() =>
[...new Set(rows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
[rows]
);
const annees = useMemo(() =>
[...new Set(rows.map(r => getYear(r.derniereentree)).filter(Boolean))].sort((a, b) => b.localeCompare(a)),
[rows]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return rows.filter(r =>
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
(!filterAnnee || getYear(r.derniereentree) === filterAnnee) &&
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
);
}, [rows, filterFournisseur, filterAnnee, search]);
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgStockNegatifRow>(
filtered,
["stockdispo"]
);
const th = (key: SK, label: string) => (
<th className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
onClick={() => handleSort(key)}>
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
</th>
);
async function handleExport() {
const ExcelJS = (await import("exceljs")).default;
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet("Stock Négatif");
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "Dernière entrée"]);
ws.getRow(1).eachCell(cell => {
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
cell.alignment = { vertical: "middle", horizontal: "center" };
});
ws.getRow(1).height = 22;
for (const r of sorted) ws.addRow([r.codein, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)]);
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 16 }, { width: 22 }];
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Stock_Negatif_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} }
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{exporting && (
<LoadingModal
message="Génération du fichier Excel"
subMessage={`${total.toLocaleString("fr-FR")} ligne${total !== 1 ? "s" : ""} en cours d'écriture…`}
/>
)}
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative"> <div className="relative">
@@ -151,8 +304,10 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin
/> />
</div> </div>
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} /> <FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
<FilterSelect label="Année entrée" value={filterAnnee} onChange={setFilterAnnee} options={annees} /> {anneeOf && (
{(filterFournisseur || filterAnnee || search) && ( <FilterSelect label={anneeLabel} value={filterAnnee} onChange={setFilterAnnee} options={annees} />
)}
{hasFilters && (
<button <button
onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }} onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }}
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors" className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
@@ -162,312 +317,170 @@ function TabStockNegatif({ rows, magasin }: { rows: PgStockNegatifRow[]; magasin
)} )}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span> <span className="text-sm text-gray-500">
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors"> {total.toLocaleString("fr-FR")} article{total !== 1 ? "s" : ""}
{hasFilters && total !== rows.length && (
<span className="text-gray-400"> / {rows.length.toLocaleString("fr-FR")}</span>
)}
</span>
<button
onClick={handleExport}
disabled={exporting || total === 0}
className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Download className="w-4 h-4" />Exporter Excel <Download className="w-4 h-4" />Exporter Excel
</button> </button>
</div> </div>
</div> </div>
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm"> <div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200"> <thead className="bg-gray-50 border-b border-gray-200">
<tr> <tr>
{th("codein", "Code article")} {columns.map(c => (
{th("libelle1", "Libellé")} <th
{th("fournisseur", "Fournisseur")} key={c.key}
{th("site", "Magasin")} onClick={() => handleSort(c.key)}
{th("stockdispo", "Stock")} className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
{th("dernierevente", "Dernière vente")} >
{th("derniereentree", "Dernière entrée")} {c.label}
<SortIcon active={sortKey === c.key} dir={sortDir} />
</th>
))}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{sorted.length === 0 && ( {pageRows.length === 0 && (
<tr><td colSpan={7} className="px-3 py-8 text-center text-gray-400">Aucun article en stock négatif</td></tr> <tr>
<td colSpan={columns.length} className="px-3 py-8 text-center text-gray-400">{emptyLabel}</td>
</tr>
)} )}
{sorted.map((row, i) => ( {pageRows.map((row, i) => (
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors"> <tr key={`${String((row as Record<string, unknown>).codein)}-${String((row as Record<string, unknown>).site)}-${i}`}
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td> className="hover:bg-gray-50 transition-colors">
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td> {columns.map(c => (
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td> <td key={c.key} className="px-3 py-2">
<td className="px-3 py-2"> {c.render
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span> ? c.render(row)
</td> : String((row as Record<string, unknown>)[c.key] ?? "")}
<td className="px-3 py-2 font-semibold text-red-600">{row.stockdispo}</td> </td>
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.dernierevente)}</td> ))}
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniereentree)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
<Pagination
page={currentPage}
totalPages={totalPages}
total={total}
from={total === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1}
to={Math.min(currentPage * PAGE_SIZE, total)}
onChange={p => setPage(Math.min(Math.max(1, p), totalPages))}
/>
</div> </div>
); );
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Onglet 2 — Entrées sans vente // Cellules partagées
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function TabEntreesSansVente({ rows, magasin }: { rows: PgStockSansVenteRow[]; magasin: string }) { const cellCode = (v: string) => <span className="font-mono text-xs text-gray-700">{v}</span>;
type SK = keyof PgStockSansVenteRow; const cellSite = (v: string) => (
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{v}</span>
const [filterFournisseur, setFilterFournisseur] = useState(""); );
const [filterAnnee, setFilterAnnee] = useState(""); const cellDate = (v: string | null) => <span className="text-gray-600 text-xs">{fmtDate(v)}</span>;
const [search, setSearch] = useState("");
const baseRows = useMemo(() => rows.filter(r => r.stock_actuel !== 0), [rows]);
const fournisseurs = useMemo(() =>
[...new Set(baseRows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")),
[baseRows]
);
const annees = useMemo(() =>
[...new Set(baseRows.map(r => getYear(r.derniere_entree)).filter(Boolean))].sort((a, b) => b.localeCompare(a)),
[baseRows]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return baseRows.filter(r =>
(!filterFournisseur || r.fournisseur === filterFournisseur) &&
(!filterAnnee || getYear(r.derniere_entree) === filterAnnee) &&
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q))
);
}, [baseRows, filterFournisseur, filterAnnee, search]);
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgStockSansVenteRow>(
filtered,
["stock_actuel"]
);
const th = (key: SK, label: string) => (
<th className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900"
onClick={() => handleSort(key)}>
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} />
</th>
);
async function handleExport() {
const ExcelJS = (await import("exceljs")).default;
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet("Entrées sans vente");
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière entrée"]);
ws.getRow(1).eachCell(cell => {
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } };
cell.font = { color: { argb: "FFFFFFFF" }, bold: true };
cell.alignment = { vertical: "middle", horizontal: "center" };
});
ws.getRow(1).height = 22;
for (const r of sorted) ws.addRow([r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_entree)]);
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 22 }];
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Entrees_Sans_Vente_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Code article ou libellé…"
className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
/>
</div>
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
<FilterSelect label="Année entrée" value={filterAnnee} onChange={setFilterAnnee} options={annees} />
{(filterFournisseur || filterAnnee || search) && (
<button
onClick={() => { setFilterFournisseur(""); setFilterAnnee(""); setSearch(""); }}
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
>
✕ Réinitialiser
</button>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
<Download className="w-4 h-4" />Exporter Excel
</button>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{th("codein", "Code article")}
{th("libelle1", "Libellé")}
{th("fournisseur", "Fournisseur")}
{th("site", "Magasin")}
{th("stock_actuel", "Stock actuel")}
{th("derniere_entree", "Dernière entrée")}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.length === 0 && (
<tr><td colSpan={6} className="px-3 py-8 text-center text-gray-400">Aucun article trouvé</td></tr>
)}
{sorted.map((row, i) => (
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
<td className="px-3 py-2">
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
</td>
<td className="px-3 py-2 font-semibold text-amber-600">{row.stock_actuel}</td>
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_entree)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Onglet 3 — Sans vente depuis 6 mois // Configuration des 3 onglets
//
// Définies au niveau module : leur identité doit rester stable d'un rendu à
// l'autre, sinon les `useMemo` de StockTable se recalculent en boucle.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function TabSansVente6Mois({ rows, magasin }: { rows: PgSansVente6MoisRow[]; magasin: string }) { type TabConfig<T> = Omit<StockTableProps<T>, "rows" | "magasin">;
type SK = keyof PgSansVente6MoisRow;
const [filterFournisseur, setFilterFournisseur] = useState(""); const NEGATIF_CONFIG: TabConfig<PgStockNegatifRow> = {
const [search, setSearch] = useState(""); fournisseurOf: r => r.fournisseur,
anneeOf: r => getYear(r.derniereentree),
searchIn: r => [r.codein, r.libelle1],
emptyLabel: "Aucun article en stock négatif",
columns: [
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
{ key: "stockdispo", label: "Stock", numeric: true, render: r => <span className="font-semibold text-red-600">{r.stockdispo}</span> },
{ key: "dernierevente", label: "Dernière vente", render: r => cellDate(r.dernierevente) },
{ key: "derniereentree", label: "Dernière entrée", render: r => cellDate(r.derniereentree) },
],
excel: {
sheet: "Stock Négatif",
filePrefix: "Stock_Negatif",
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente", "Dernière entrée"],
widths: [16, 16, 10, 10, 16, 22],
row: r => [r.codein, "", Math.abs(r.stockdispo), "503", fmtDate(r.dernierevente), fmtDate(r.derniereentree)],
},
};
const fournisseurs = useMemo(() => const SANS_VENTE_CONFIG: TabConfig<PgStockSansVenteRow> = {
[...new Set(rows.map(r => r.fournisseur).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fr")), fournisseurOf: r => r.fournisseur,
[rows] anneeOf: r => getYear(r.derniere_entree),
); searchIn: r => [r.codein, r.libelle1],
emptyLabel: "Aucun article trouvé",
columns: [
{ key: "codein", label: "Code article", render: r => cellCode(r.codein) },
{ key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
{ key: "site", label: "Magasin", render: r => cellSite(r.site) },
{ key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => <span className="font-semibold text-amber-600">{r.stock_actuel}</span> },
{ key: "derniere_entree", label: "Dernière entrée", render: r => cellDate(r.derniere_entree) },
],
excel: {
sheet: "Entrées sans vente",
filePrefix: "Entrees_Sans_Vente",
header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière entrée"],
widths: [16, 16, 10, 10, 22],
row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_entree)],
},
};
const filtered = useMemo(() => { const SANS_VENTE_6MOIS_CONFIG: TabConfig<PgSansVente6MoisRow> = {
const q = search.trim().toLowerCase(); fournisseurOf: r => r.fournisseur,
return rows.filter(r => searchIn: r => [r.codein, r.libelle1],
(!filterFournisseur || r.fournisseur === filterFournisseur) && emptyLabel: "Aucun article trouvé",
(!q || r.codein?.toLowerCase().includes(q) || r.libelle1?.toLowerCase().includes(q)) columns: [
); { key: "codein", label: "Code article", render: r => cellCode(r.codein) },
}, [rows, filterFournisseur, search]); { key: "libelle1", label: "Libellé", render: r => <span className="text-gray-900">{r.libelle1}</span> },
{ key: "fournisseur", label: "Fournisseur", render: r => <span className="text-gray-700">{r.fournisseur}</span> },
const { sorted, sortKey, sortDir, handleSort } = useSortedRows<PgSansVente6MoisRow & Record<string, unknown>>( { key: "site", label: "Magasin", render: r => cellSite(r.site) },
filtered as (PgSansVente6MoisRow & Record<string, unknown>)[], { key: "stock_actuel", label: "Stock actuel", numeric: true, render: r => <span className="font-semibold text-amber-600">{r.stock_actuel}</span> },
["stock_actuel", "jours_sans_vente"] { key: "derniere_vente", label: "Dernière vente", render: r => cellDate(r.derniere_vente) },
); {
key: "jours_sans_vente",
const th = (key: SK, label: string) => ( label: "Jours sans vente",
<th className="cursor-pointer select-none whitespace-nowrap px-3 py-2.5 text-left text-xs font-semibold text-gray-600 uppercase tracking-wide hover:text-gray-900" numeric: true,
onClick={() => handleSort(key as string & keyof (PgSansVente6MoisRow & Record<string, unknown>))}> render: r => (
{label}<SortIcon col={key as string} sortKey={sortKey as string} sortDir={sortDir} /> <span
</th> className="tabular-nums font-medium"
); style={{ color: r.jours_sans_vente && r.jours_sans_vente > 365 ? "#ef4444" : "#6b7280" }}
>
async function handleExport() { {r.jours_sans_vente != null ? r.jours_sans_vente.toLocaleString("fr-FR") : "—"}
const ExcelJS = (await import("exceljs")).default; </span>
const wb = new ExcelJS.Workbook(); ),
const ws = wb.addWorksheet("Sans vente 6 mois"); },
ws.addRow(["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente"]); { key: "derniere_entree", label: "Dernière entrée", render: r => cellDate(r.derniere_entree) },
ws.getRow(1).eachCell(cell => { ],
cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1E293B" } }; excel: {
cell.font = { color: { argb: "FFFFFFFF" }, bold: true }; sheet: "Sans vente 6 mois",
cell.alignment = { vertical: "middle", horizontal: "center" }; filePrefix: "Sans_Vente_6Mois",
}); header: ["CODEIN", "GENCODE", "QTE", "CODMV", "Dernière vente"],
ws.getRow(1).height = 22; widths: [16, 16, 10, 10, 22],
for (const r of sorted) ws.addRow([r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_vente)]); row: r => [r.codein, "", r.stock_actuel, "412", fmtDate(r.derniere_vente)],
ws.columns = [{ width: 16 }, { width: 16 }, { width: 10 }, { width: 10 }, { width: 22 }]; },
const buffer = await wb.xlsx.writeBuffer(); };
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Sans_Vente_6Mois_${magasin || "Tous"}_${new Date().toISOString().slice(0, 10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Code article ou libellé…"
className="rounded-lg border border-gray-200 bg-white pl-8 pr-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px]"
/>
</div>
<FilterSelect label="Fournisseur" value={filterFournisseur} onChange={setFilterFournisseur} options={fournisseurs} />
{(filterFournisseur || search) && (
<button
onClick={() => { setFilterFournisseur(""); setSearch(""); }}
className="rounded-lg border border-orange-200 bg-orange-50 px-3 py-1.5 text-xs font-medium text-orange-700 hover:bg-orange-100 transition-colors"
>
✕ Réinitialiser
</button>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">{sorted.length} article{sorted.length !== 1 ? "s" : ""}</span>
<button onClick={handleExport} className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-emerald-700 transition-colors">
<Download className="w-4 h-4" />Exporter Excel
</button>
</div>
</div>
<div className="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow-sm">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{th("codein", "Code article")}
{th("libelle1", "Libellé")}
{th("fournisseur", "Fournisseur")}
{th("site", "Magasin")}
{th("stock_actuel", "Stock actuel")}
{th("derniere_vente", "Dernière vente")}
{th("jours_sans_vente", "Jours sans vente")}
{th("derniere_entree", "Dernière entrée")}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{sorted.length === 0 && (
<tr><td colSpan={8} className="px-3 py-8 text-center text-gray-400">Aucun article trouvé</td></tr>
)}
{sorted.map((row, i) => (
<tr key={`${row.codein}-${row.site}-${i}`} className="hover:bg-gray-50 transition-colors">
<td className="px-3 py-2 font-mono text-xs text-gray-700">{row.codein}</td>
<td className="px-3 py-2 text-gray-900">{row.libelle1}</td>
<td className="px-3 py-2 text-gray-700">{row.fournisseur}</td>
<td className="px-3 py-2">
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{row.site}</span>
</td>
<td className="px-3 py-2 font-semibold text-amber-600">{row.stock_actuel}</td>
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_vente)}</td>
<td className="px-3 py-2 text-right tabular-nums font-medium"
style={{ color: row.jours_sans_vente && row.jours_sans_vente > 365 ? "#ef4444" : "#6b7280" }}>
{row.jours_sans_vente != null ? row.jours_sans_vente.toLocaleString("fr-FR") : "—"}
</td>
<td className="px-3 py-2 text-gray-600 text-xs">{fmtDate(row.derniere_entree)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Composant principal avec onglets // Composant principal avec onglets
@@ -481,6 +494,10 @@ const TABS = [
type TabKey = typeof TABS[number]["key"]; type TabKey = typeof TABS[number]["key"];
function isTabKey(v: string): v is TabKey {
return TABS.some(t => t.key === v);
}
interface Props { interface Props {
rowsNegatif: PgStockNegatifRow[]; rowsNegatif: PgStockNegatifRow[];
rowsSansVente: PgStockSansVenteRow[]; rowsSansVente: PgStockSansVenteRow[];
@@ -492,25 +509,59 @@ interface Props {
export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6Mois, magasin, tab, sites }: Props) { export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6Mois, magasin, tab, sites }: Props) {
const router = useRouter(); const router = useRouter();
const activeTab: TabKey = (TABS.some(t => t.key === tab) ? tab : "negatif") as TabKey; const initialTab: TabKey = isTabKey(tab) ? tab : "negatif";
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
// Le changement d'onglet est purement client : les 3 jeux de données sont déjà chargés.
const [switching, startSwitch] = useTransition();
// Le changement de magasin, lui, relance les requêtes serveur.
const [navigating, startNavigation] = useTransition();
// Resynchronise l'onglet si l'URL change côté serveur (retour arrière, lien direct).
const [urlTab, setUrlTab] = useState(initialTab);
if (urlTab !== initialTab) {
setUrlTab(initialTab);
setActiveTab(initialTab);
}
function buildUrl(nextTab: TabKey, nextMagasin: string) {
const params = new URLSearchParams();
if (nextMagasin) params.set("magasin", nextMagasin);
params.set("tab", nextTab);
return `/stock-negatif?${params.toString()}`;
}
function handleMagasinChange(e: React.ChangeEvent<HTMLSelectElement>) { function handleMagasinChange(e: React.ChangeEvent<HTMLSelectElement>) {
const val = e.target.value; const val = e.target.value;
const params = new URLSearchParams(); startNavigation(() => {
if (val) params.set("magasin", val); router.push(buildUrl(activeTab, val));
params.set("tab", activeTab); });
router.push(`/stock-negatif?${params.toString()}`);
} }
function handleTabChange(key: TabKey) { function handleTabChange(key: TabKey) {
const params = new URLSearchParams(); if (key === activeTab || switching) return;
if (magasin) params.set("magasin", magasin); // Mise à jour de l'URL sans navigation serveur : aucune requête SQL n'est rejouée.
params.set("tab", key); window.history.replaceState(null, "", buildUrl(key, magasin));
router.push(`/stock-negatif?${params.toString()}`); startSwitch(() => setActiveTab(key));
} }
const counts: Record<TabKey, number> = useMemo(() => ({
"negatif": rowsNegatif.length,
"sans-vente": rowsSansVente.length,
"sans-vente-6mois": rowsSansVente6Mois.length,
}), [rowsNegatif, rowsSansVente, rowsSansVente6Mois]);
const busy = switching || navigating;
const showLoader = useDelayedFlag(busy);
const loaderMessage = navigating ? "Chargement des données" : "Préparation de l'onglet";
const loaderSubMessage = navigating
? "Interrogation du stock pour le magasin sélectionné…"
: "Application des filtres et du tri…";
return ( return (
<div className="space-y-4"> <div className="space-y-4" aria-busy={busy}>
{showLoader && <LoadingModal message={loaderMessage} subMessage={loaderSubMessage} />}
{/* Toolbar */} {/* Toolbar */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -518,7 +569,8 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M
<select <select
value={magasin} value={magasin}
onChange={handleMagasinChange} onChange={handleMagasinChange}
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500" disabled={navigating}
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-wait disabled:opacity-60"
> >
<option value="">Tous les magasins</option> <option value="">Tous les magasins</option>
{sites.map(s => ( {sites.map(s => (
@@ -531,37 +583,45 @@ export function GestionStockClient({ rowsNegatif, rowsSansVente, rowsSansVente6M
{/* Tabs */} {/* Tabs */}
<div className="border-b border-gray-200"> <div className="border-b border-gray-200">
<nav className="-mb-px flex gap-1"> <nav className="-mb-px flex gap-1">
{TABS.map(t => ( {TABS.map(t => {
<button const isActive = activeTab === t.key;
key={t.key} return (
onClick={() => handleTabChange(t.key)} <button
className={[ key={t.key}
"px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap", onClick={() => handleTabChange(t.key)}
activeTab === t.key disabled={busy}
? "border-blue-600 text-blue-600" aria-current={isActive ? "page" : undefined}
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300", className={[
].join(" ")} "px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap",
> isActive
{t.label} ? "border-blue-600 text-blue-600"
<span className={[ : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300",
"ml-2 rounded-full px-2 py-0.5 text-xs font-semibold", busy ? "cursor-wait" : "",
activeTab === t.key ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500", ].join(" ")}
].join(" ")}> >
{t.key === "negatif" {t.label}
? rowsNegatif.length <span className={[
: t.key === "sans-vente" "ml-2 rounded-full px-2 py-0.5 text-xs font-semibold",
? rowsSansVente.filter(r => r.stock_actuel !== 0).length isActive ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-500",
: rowsSansVente6Mois.length} ].join(" ")}>
</span> {counts[t.key].toLocaleString("fr-FR")}
</button> </span>
))} </button>
);
})}
</nav> </nav>
</div> </div>
{/* Tab content */} {/* Tab content */}
{activeTab === "negatif" && <TabStockNegatif rows={rowsNegatif} magasin={magasin} />} {activeTab === "negatif" && (
{activeTab === "sans-vente" && <TabEntreesSansVente rows={rowsSansVente} magasin={magasin} />} <StockTable<PgStockNegatifRow> rows={rowsNegatif} magasin={magasin} {...NEGATIF_CONFIG} />
{activeTab === "sans-vente-6mois" && <TabSansVente6Mois rows={rowsSansVente6Mois} magasin={magasin} />} )}
{activeTab === "sans-vente" && (
<StockTable<PgStockSansVenteRow> rows={rowsSansVente} magasin={magasin} {...SANS_VENTE_CONFIG} />
)}
{activeTab === "sans-vente-6mois" && (
<StockTable<PgSansVente6MoisRow> rows={rowsSansVente6Mois} magasin={magasin} {...SANS_VENTE_6MOIS_CONFIG} />
)}
</div> </div>
); );
} }
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { pgDiagFournisseurDerniereEntree } from "@/lib/pg-ff-client";
export const dynamic = "force-dynamic";
/**
* Diagnostic de l'attribution « fournisseur de la dernière entrée » utilisée par
* la page Gestion de Stock.
*
* `colonneDetectee: null` signifie que `mvtart` ne porte aucune des colonnes
* fournisseur connues : la page retombe alors sur `artfou1.preference = 1`.
* `colonnesMvtart` liste ce qui existe réellement, pour compléter la liste
* blanche `MVTART_FOU_CANDIDATES` si le nom diffère.
*/
export async function GET() {
try {
return NextResponse.json(await pgDiagFournisseurDerniereEntree());
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 });
}
}
+157 -23
View File
@@ -1030,6 +1030,143 @@ export async function pgGetStockForCodeins(codeins: string[]): Promise<Map<strin
return map; return map;
} }
// ---------------------------------------------------------------------------
// Stock — fournisseur de la dernière entrée
// ---------------------------------------------------------------------------
/**
* Colonnes candidates de `mvtart` reliant un mouvement de stock à un fournisseur.
* `mvtart` provient d'une réplication FF : le nom exact n'est pas garanti, on le
* détecte une fois par process (même principe que `getNomenclatureParentCol`).
* - target "artfou1" : la colonne est une FK vers `artfou1.no_id`
* - target "code" : la colonne contient directement le code fournisseur (`fouident.code`)
*/
const MVTART_FOU_CANDIDATES: { column: string; target: "artfou1" | "code" }[] = [
{ column: "artfou1_no_id", target: "artfou1" },
{ column: "artfou_no_id", target: "artfou1" },
{ column: "afou_no_id", target: "artfou1" },
{ column: "codefou", target: "code" },
{ column: "code_fou", target: "code" },
{ column: "fou_code", target: "code" },
{ column: "fournisseur", target: "code" },
];
let _mvtartFouLink: { column: string; target: "artfou1" | "code" } | null | undefined = undefined;
async function getMvtartFouLink() {
if (_mvtartFouLink !== undefined) return _mvtartFouLink;
try {
const meta = await db.execute(sql`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'mvtart'
`);
const cols = new Set(
(meta.rows as { column_name: string }[]).map(r => r.column_name.toLowerCase())
);
// Le nom vient d'information_schema ET doit figurer dans la liste blanche
// ci-dessus : aucune valeur utilisateur n'atteint le SQL.
_mvtartFouLink = MVTART_FOU_CANDIDATES.find(c => cols.has(c.column)) ?? null;
} catch (e) {
console.error("[pg-ff] Détection mvtart.fournisseur impossible:", e);
_mvtartFouLink = null;
}
console.log("[pg-ff] mvtart → fournisseur:", _mvtartFouLink ?? "aucune colonne (repli artfou1.preference=1)");
return _mvtartFouLink;
}
/**
* Jointure latérale sur la DERNIÈRE entrée en stock (`genremvt IN (1,2)`) du
* couple article/site : elle expose `lastentry.datmvt` (date) et
* `lastentry.code` (code fournisseur, `NULL` si la base ne porte pas
* l'information).
*
* Règle métier : un article n'est rattaché qu'au fournisseur de sa dernière
* entrée. S'il est rentré en stock chez un autre fournisseur, il ne doit plus
* apparaître sous le précédent.
*
* La `LEFT JOIN artfou1` (et non `JOIN`) garantit qu'on retient bien la vraie
* dernière entrée, même si son fournisseur est inconnu.
*/
function lastEntryJoin(
link: { column: string; target: "artfou1" | "code" } | null,
artnoidExpr: SQL,
siteExpr: SQL,
): SQL {
const codeExpr = link
? (link.target === "artfou1" ? sql`afe.code` : sql.raw(`me.${link.column}`))
: sql`NULL::text`;
const joinArtfou = link && link.target === "artfou1"
? sql`LEFT JOIN artfou1 afe ON afe.no_id = ${sql.raw(`me.${link.column}`)}`
: sql``;
return sql`
LEFT JOIN LATERAL (
SELECT me.datmvt, ${codeExpr} AS code
FROM mvtart me
${joinArtfou}
WHERE me.artnoid = ${artnoidExpr}
AND me.site = ${siteExpr}
AND me.genremvt IN (1, 2)
ORDER BY me.datmvt DESC, me.no_id DESC
LIMIT 1
) lastentry ON TRUE
${link ? sql`LEFT JOIN fouident flast ON flast.code = lastentry.code` : sql``}
`;
}
/**
* Libellé fournisseur affiché : celui de la dernière entrée quand la base le
* permet, sinon repli documenté sur `artfou1.preference = 1`.
*/
function fournisseurExpr(link: { column: string; target: "artfou1" | "code" } | null): SQL {
return link
? sql`COALESCE(flast.nom, lastentry.code, f.nom, af.code)`
: sql`COALESCE(f.nom, af.code)`;
}
/**
* Diagnostic (`/api/diag/stock-fournisseur`) : expose la colonne détectée, les
* colonnes réellement présentes sur `mvtart` et un échantillon comparant le
* fournisseur principal au fournisseur de la dernière entrée.
*/
export async function pgDiagFournisseurDerniereEntree() {
const link = await getMvtartFouLink();
const meta = await db.execute(sql`
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'mvtart' ORDER BY ordinal_position
`);
let echantillon: unknown[] = [];
if (link) {
const r = await pgNoParallel(sql`
SELECT
a.codein,
cs.site,
COALESCE(f.nom, af.code) AS fournisseur_preference,
COALESCE(flast.nom, lastentry.code) AS fournisseur_derniere_entree,
lastentry.datmvt::text AS derniere_entree
FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid
JOIN (
SELECT DISTINCT ON (art_no_id) art_no_id, code
FROM artfou1
WHERE preference = 1
ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id
LEFT JOIN fouident f ON f.code = af.code
${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)}
WHERE cs.qte < 0
AND cs.site IN ('292', '579')
LIMIT 20
`);
echantillon = r.rows;
}
return {
colonneDetectee: link,
candidatsTestes: MVTART_FOU_CANDIDATES.map(c => c.column),
colonnesMvtart: meta.rows,
echantillon,
};
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Stock négatif — tous magasins ou par site // Stock négatif — tous magasins ou par site
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1044,10 +1181,6 @@ export interface PgStockNegatifRow {
derniereentree: string | null; derniereentree: string | null;
} }
/**
* Retourne tous les articles avec un stock disponible négatif.
* Si `site` est fourni, filtre sur ce magasin uniquement.
*/
export interface PgStockSansVenteRow { export interface PgStockSansVenteRow {
codein: string; codein: string;
libelle1: string; libelle1: string;
@@ -1060,22 +1193,30 @@ export interface PgStockSansVenteRow {
/** /**
* Retourne les articles ayant des entrées en stock (hors mois courant) * Retourne les articles ayant des entrées en stock (hors mois courant)
* mais aucune vente enregistrée sur ce site. * mais aucune vente enregistrée sur ce site.
* Les articles dont le stock actuel est nul sont exclus (rien à régulariser).
*/ */
export async function pgGetStockSansVente(site?: string): Promise<PgStockSansVenteRow[]> { export async function pgGetStockSansVente(site?: string): Promise<PgStockSansVenteRow[]> {
const link = await getMvtartFouLink();
const siteFilter = site ? sql`AND m.site = ${site}` : sql``; const siteFilter = site ? sql`AND m.site = ${site}` : sql``;
const result = await pgNoParallel(sql` const result = await pgNoParallel(sql`
SELECT SELECT
a.codein, a.codein,
COALESCE(a.libelle1, '') AS libelle1, COALESCE(a.libelle1, '') AS libelle1,
COALESCE(f.nom, af.code) AS fournisseur, ${fournisseurExpr(link)} AS fournisseur,
m.site, m.site,
COALESCE(cs.qte::float, 0) AS stock_actuel, COALESCE(cs.qte::float, 0) AS stock_actuel,
MAX(m.datmvt)::text AS derniere_entree MAX(m.datmvt)::text AS derniere_entree
FROM mvtart m FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid JOIN articles a ON a.no_id = m.artnoid
JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1 JOIN (
SELECT DISTINCT ON (art_no_id) art_no_id, code
FROM artfou1
WHERE preference = 1
ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id
JOIN fouident f ON f.code = af.code JOIN fouident f ON f.code = af.code
LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site LEFT JOIN cube_stock cs ON cs.artnoid = a.no_id AND cs.site = m.site
${lastEntryJoin(link, sql`a.no_id`, sql`m.site`)}
WHERE m.genremvt IN (1, 2) WHERE m.genremvt IN (1, 2)
AND m.site IN ('292', '579') AND m.site IN ('292', '579')
AND date_trunc('month', m.datmvt) < date_trunc('month', CURRENT_DATE) AND date_trunc('month', m.datmvt) < date_trunc('month', CURRENT_DATE)
@@ -1086,29 +1227,25 @@ export async function pgGetStockSansVente(site?: string): Promise<PgStockSansVen
AND mv2.site = m.site AND mv2.site = m.site
AND mv2.genremvt = 3 AND mv2.genremvt = 3
) )
GROUP BY a.codein, a.libelle1, f.nom, af.code, m.site, cs.qte GROUP BY a.codein, a.libelle1, ${fournisseurExpr(link)}, m.site, cs.qte
HAVING COALESCE(cs.qte::float, 0) <> 0
ORDER BY MAX(m.datmvt) DESC ORDER BY MAX(m.datmvt) DESC
`); `);
return result.rows as PgStockSansVenteRow[]; return result.rows as PgStockSansVenteRow[];
} }
export async function pgGetStockNegatif(site?: string): Promise<PgStockNegatifRow[]> { export async function pgGetStockNegatif(site?: string): Promise<PgStockNegatifRow[]> {
const link = await getMvtartFouLink();
const siteFilter = site ? sql`AND cs.site = ${site}` : sql``; const siteFilter = site ? sql`AND cs.site = ${site}` : sql``;
const result = await pgNoParallel(sql` const result = await pgNoParallel(sql`
SELECT SELECT
a.codein, a.codein,
COALESCE(a.libelle1, '') AS libelle1, COALESCE(a.libelle1, '') AS libelle1,
COALESCE(f.nom, af.code) AS fournisseur, ${fournisseurExpr(link)} AS fournisseur,
cs.site, cs.site,
cs.qte::float AS stockdispo, cs.qte::float AS stockdispo,
cs.dernierevente::text, cs.dernierevente::text,
( lastentry.datmvt::text AS derniereentree
SELECT MAX(m.datmvt)::text
FROM mvtart m
WHERE m.artnoid = cs.artnoid
AND m.site = cs.site
AND m.genremvt IN (1, 2)
) AS derniereentree
FROM cube_stock cs FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid JOIN articles a ON a.no_id = cs.artnoid
JOIN ( JOIN (
@@ -1118,6 +1255,7 @@ export async function pgGetStockNegatif(site?: string): Promise<PgStockNegatifRo
ORDER BY art_no_id, code ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id ) af ON af.art_no_id = a.no_id
JOIN fouident f ON f.code = af.code JOIN fouident f ON f.code = af.code
${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)}
WHERE cs.qte < 0 WHERE cs.qte < 0
AND cs.site IN ('292', '579') AND cs.site IN ('292', '579')
${siteFilter} ${siteFilter}
@@ -1147,12 +1285,13 @@ export interface PgSansVente6MoisRow {
* ET dont la dernière entrée en stock remonte aussi à plus de 6 mois. * ET dont la dernière entrée en stock remonte aussi à plus de 6 mois.
*/ */
export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6MoisRow[]> { export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6MoisRow[]> {
const link = await getMvtartFouLink();
const siteFilter = site ? sql`AND cs.site = ${site}` : sql``; const siteFilter = site ? sql`AND cs.site = ${site}` : sql``;
const result = await pgNoParallel(sql` const result = await pgNoParallel(sql`
SELECT SELECT
a.codein, a.codein,
COALESCE(a.libelle1, '') AS libelle1, COALESCE(a.libelle1, '') AS libelle1,
COALESCE(f.nom, af.code) AS fournisseur, ${fournisseurExpr(link)} AS fournisseur,
cs.site, cs.site,
cs.qte::float AS stock_actuel, cs.qte::float AS stock_actuel,
cs.dernierevente::text AS derniere_vente, cs.dernierevente::text AS derniere_vente,
@@ -1160,13 +1299,7 @@ export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6Mo
WHEN cs.dernierevente IS NULL THEN NULL WHEN cs.dernierevente IS NULL THEN NULL
ELSE (CURRENT_DATE - cs.dernierevente::date) ELSE (CURRENT_DATE - cs.dernierevente::date)
END AS jours_sans_vente, END AS jours_sans_vente,
( lastentry.datmvt::text AS derniere_entree
SELECT MAX(m.datmvt)::text
FROM mvtart m
WHERE m.artnoid = cs.artnoid
AND m.site = cs.site
AND m.genremvt IN (1, 2)
) AS derniere_entree
FROM cube_stock cs FROM cube_stock cs
JOIN articles a ON a.no_id = cs.artnoid JOIN articles a ON a.no_id = cs.artnoid
JOIN ( JOIN (
@@ -1176,6 +1309,7 @@ export async function pgGetSansVente6Mois(site?: string): Promise<PgSansVente6Mo
ORDER BY art_no_id, code ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id ) af ON af.art_no_id = a.no_id
JOIN fouident f ON f.code = af.code JOIN fouident f ON f.code = af.code
${lastEntryJoin(link, sql`cs.artnoid`, sql`cs.site`)}
WHERE cs.site IN ('292', '579') WHERE cs.site IN ('292', '579')
AND cs.qte > 0 AND cs.qte > 0
AND ( AND (