Lot 4 : Grille servie depuis l'instantané et requêtes allégées

- Grille : lecture de l'instantané grid_rows (gamme INIT et dernières gammes
  enregistrées reportées), recalcul en arrière-plan au-delà de 20 h, version de
  format dans chaque ligne pour écarter les anciens instantanés.
- Navigateur : lignes réutilisées au retour sur la Grille (< 10 min), Grille
  conservée pendant un changement de magasin ou une actualisation, React.memo
  sur la Grille, les filtres et la barre du bas, en-tête mémorisé.
- Fiche produit : toutes les lectures en parallèle.
- Ventes par mois et stock sans vente : plages de dates au lieu de TO_CHAR.
- Accueil : vagues de 12 appels, cache de 30 min.
- API v1 : comptage et page en parallèle, last_used_at au plus une fois par
  minute, version interne non exposée.
- Plan : suivi du lot 4, avec ce qui reste à valider sur la base réelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
Claude committed 2026-10-03 21:15:33 +00:00
1 parent 220ca2263a
commit b7ee413d67
13 files changed
+393 -119

No files matched your search

+43
View File
@@ -333,3 +333,46 @@ Vérifié dans un navigateur : sur les pages visitées, en clair et en sombre, a
Autres changements :
- **Composants retirés** : `SuccessModal`, `ConfirmModal`, `ChangePasswordModal`, `LoadingModal`, `export-dropdown`, `financial-cell` et `supplier-selection`, qui n'étaient plus utilisés.
- **Correction** : le nom de l'utilisateur est maintenant transmis à la session, et l'en-tête l'affiche.
## Suivi — Lot 4 réalisé
**Grille servie depuis l'instantané `grid_rows`**
- `getProductRows` lit d'abord l'instantané du fournisseur (`readGridSnapshot`), puis y reporte :
- la gamme FF du moment (INIT) ;
- les gammes du dernier enregistrement.
- Hors « Nos 2 magasins », le rattrapage du magasin choisi est appliqué comme après un calcul complet.
- L'instantané n'est pas utilisé dans trois cas, et la Grille est alors recalculée comme avant :
- il est absent ;
- il date d'un autre mois (la fenêtre des 12 mois a changé) ;
- il est d'un ancien format. Un numéro de version (`payloadVersion`) est désormais écrit dans chaque ligne, donc les instantanés existants seront recalculés une fois, à la première ouverture ou par la synchro de la nuit.
- Instantané de plus de 20 h : il est servi tout de suite, et un recalcul part en arrière-plan.
- « Actualiser » force toujours le calcul complet.
- Mesure locale (30 lignes) : 43 ms depuis l'instantané, 1 ms depuis le cache mémoire.
**Grille côté navigateur**
- Retour sur la Grille (même fournisseur et même magasin, moins de 10 min) : les lignes en mémoire sont réutilisées, sans nouveau téléchargement.
- Changement de magasin ou « Actualiser » sur le même fournisseur : la Grille reste affichée et utilisable. Les chiffres complétés remplacent l'affichage d'un bloc, une fois reçus.
- `React.memo` sur la Grille, la barre de filtres et la barre du bas. En-tête du tableau mémorisé. Virtualiseur sans `flushSync`.
**Requêtes**
- **Fiche produit** : les sept lectures, y compris le réseau Qlik, partent en même temps au lieu de deux vagues successives.
- **Ventes par mois** : filtre sur des plages de dates au lieu de `TO_CHAR(datmvt)`, ce qui permet à PostgreSQL d'utiliser un index sur `datmvt`. Résultats identiques vérifiés sur une base de test (colonnes `date` et `timestamp`, bornes de mois).
- **Stock sans vente** : même principe pour la borne « avant le mois en cours ».
- **Accueil** :
- enrichissement des produits par vagues de 12 appels au lieu de 5 ;
- cache porté à 30 min, puisque ce sont les ventes de la veille.
- **API `/api/v1`** :
- le comptage et la page sont lus en parallèle ;
- `last_used_at` d'une clé est écrit au plus une fois par minute ;
- la version interne des lignes n'est pas exposée.
**Non fait volontairement** (à reprendre avec un accès à la base réelle)
- Réécritures SQL lourdes : Meilleures ventes par `artnoid`, Stock sans vente agrégé d'abord, tableau de bord en une requête SQL au lieu des appels HTTP. Sans le schéma ni les volumes de la base FF, impossible de vérifier l'équivalence des résultats et le gain par `EXPLAIN ANALYZE`.
- Index sur les tables FF (`mouvements.datmvt`, `codein`) : ces tables ne sont pas gérées par CollectFlow. À proposer à l'administrateur de la base après `EXPLAIN ANALYZE`.
- Pagination par curseur de `/api/v1/grid` : elle changerait le contrat public (`page`) utilisé par des appelants externes.
- Suppression des dépendances inutilisées (`@tanstack/react-query`, `@google/genai`, `ai`, `react-markdown`, `xlsx`) : le dépôt a deux fichiers de verrouillage (npm et pnpm). À faire avec le gestionnaire de paquets réellement utilisé en production.
**À vérifier en production**
- Première ouverture d'un gros fournisseur après la synchro de la nuit : elle doit être quasi immédiate.
- Changement de magasin : la Grille reste affichée, puis les chiffres se mettent à jour.
- Enregistrer, quitter la Grille, puis y revenir : les gammes enregistrées sont bien là.
+2 -1
View File
@@ -18,7 +18,8 @@ export const metadata: Metadata = { title: "Accueil" };
const getCachedDashboardData = unstable_cache(
pgGetDashboardData,
["dashboard-data"],
{ revalidate: 600 } // 10 minutes
// 30 minutes : ce sont les ventes de la veille, elles ne changent pas dans la journée.
{ revalidate: 1800 }
);
// ---------------------------------------------------------------------------
+70 -8
View File
@@ -18,7 +18,7 @@ import { eq, desc } from "drizzle-orm";
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache";
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée.
import { upsertGridRows } from "@/lib/grid-store";
import { readGridSnapshot, upsertGridRows } from "@/lib/grid-store";
interface GetProductRowsInput {
codeFournisseur: string;
@@ -156,6 +156,14 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
return pending;
}
// Instantané persisté (calcul de la nuit ou de la dernière ouverture) : servi
// en une lecture au lieu d'un recalcul de 10 à 40 s. Jamais en rechargement
// forcé — c'est précisément ce que l'utilisateur demande d'éviter.
if (!input.forceRefresh) {
const servi = await serveFromSnapshot(input, cacheKey);
if (servi) return servi;
}
const versionAuDepart = gammeSaveVersion.get(input.codeFournisseur) ?? 0;
const promise = buildProductRows(input).then(async (rows) => {
if ((gammeSaveVersion.get(input.codeFournisseur) ?? 0) !== versionAuDepart) {
@@ -178,6 +186,65 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
return promise;
}
/** Au-delà, l'instantané est servi mais un recalcul est relancé en arrière-plan. */
const SNAPSHOT_REFRESH_AFTER_MS = 20 * 60 * 60 * 1000;
/**
* Sert la Grille depuis `grid_rows` quand c'est possible : mêmes lignes que le
* calcul en direct (elles en sont issues), avec la gamme FF du moment (INIT) et
* les dernières gammes enregistrées reportées dessus. Hors « tous magasins »,
* le rattrapage du magasin choisi est appliqué comme après un calcul.
*
* `null` = pas d'instantané utilisable (absent, ancien format, autre mois) :
* l'appelant recalcule.
*/
async function serveFromSnapshot(input: GetProductRowsInput, cacheKey: string): Promise<ProductRow[] | null> {
const { codeFournisseur, magasin = "TOTAL" } = input;
let snapshot: Awaited<ReturnType<typeof readGridSnapshot>>;
try {
snapshot = await readGridSnapshot(codeFournisseur);
} catch (e) {
console.error("[getProductRows] lecture grid_rows KO:", (e as Error).message?.slice(0, 200));
return null;
}
if (!snapshot || snapshot.rows.length === 0) return null;
const { rows } = snapshot;
const [changes] = await Promise.all([
loadLatestSnapshotChanges(codeFournisseur),
refreshGammeInit(rows, codeFournisseur),
]);
applySnapshotChanges(rows, changes);
if (magasin !== "TOTAL") {
const { dateDebut, dateFin } = buildLast12MonthsRange();
await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, last12Periods());
}
cacheGridRows(cacheKey, rows);
// Instantané qui n'a pas été recalculé par la nuit : on le rafraîchit sans
// faire attendre l'utilisateur (la prochaine ouverture en profitera).
// Toujours le calcul « tous magasins » : c'est lui qui réécrit l'instantané.
if (Date.now() - snapshot.computedAt.getTime() > SNAPSHOT_REFRESH_AFTER_MS && !gridRowsPending.has(`${codeFournisseur}:TOTAL`)) {
void getProductRows({ codeFournisseur, magasin: "TOTAL", forceRefresh: true }).catch((e) =>
console.error("[getProductRows] rafraîchissement en arrière-plan KO:", (e as Error).message?.slice(0, 200)),
);
}
return rows;
}
/** Les 12 derniers mois complets, clés « YYYYMM » triées (même fenêtre que le calcul). */
function last12Periods(): string[] {
const now = new Date();
const periods: string[] = [];
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
periods.push(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
return periods;
}
async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
const { codeFournisseur, magasin = "TOTAL" } = input;
console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`);
@@ -217,13 +284,8 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`);
// ─── Phase 2 : Fenêtre temporelle 12 mois complets ───────────────────
const now = new Date();
const allowedPeriods = new Set<string>();
for (let i = 12; i >= 1; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
allowedPeriods.add(`${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`);
}
const sortedPeriods = [...allowedPeriods].sort();
const sortedPeriods = last12Periods();
const allowedPeriods = new Set(sortedPeriods);
// ─── Phase 3 : Seed productMap depuis articles ────────────────────────
const productMap = new Map<string, ProductRow>();
@@ -1,5 +1,7 @@
"use client";
import React from "react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { SyncQlikButton } from "./sync-qlik-button";
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
@@ -49,7 +51,7 @@ function messageErreur(err: unknown): string {
* sur les gammes (annuler, enregistrer, exporter). Il y avait des doublons dans
* l'en-tête, avec des droits différents.
*/
export function FloatingSummaryBar({ isAdmin, nomFournisseur }: { isAdmin: boolean; nomFournisseur: string }) {
function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean; nomFournisseur: string }) {
const summary = useGridStore((s) => s.summary);
const resetDrafts = useGridStore((s) => s.resetDrafts);
const rows = useGridStore((s) => s.rows);
@@ -272,3 +274,9 @@ export function FloatingSummaryBar({ isAdmin, nomFournisseur }: { isAdmin: boole
</div>
);
}
/**
* Mémoïsé : ses props sont stables, il n'a donc pas à se redessiner quand le
* parent change d'état (sélection, progression du chargement…).
*/
export const FloatingSummaryBar = React.memo(FloatingSummaryBarInner);
+32 -3
View File
@@ -114,9 +114,33 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
const forceRefresh = refreshRequest !== servedRefreshRef.current;
if (forceRefresh) params.set("refresh", "1");
const cle = `${codeFournisseur}:${magasin || "TOTAL"}`;
const { rowsMeta, rows: lignesEnMemoire, setRowsMeta } = useGridStore.getState();
// Retour sur la Grille (même fournisseur, même magasin, chargée il y a
// moins de 10 min) : les lignes sont encore en mémoire, rien à retélécharger.
if (!forceRefresh && rowsMeta?.key === cle && lignesEnMemoire.length > 0
&& Date.now() - rowsMeta.loadedAt < 10 * 60 * 1000) {
setRowsLoaded(lignesEnMemoire.length);
setTotalRows(lignesEnMemoire.length);
setIsLoadingRows(false);
return;
}
// Changement de magasin (ou rechargement) sur le même fournisseur : la
// Grille reste affichée — les lignes portent déjà le détail par magasin,
// la bascule est immédiate — et les chiffres complétés pour ce magasin
// remplacent l'affichage d'un bloc, une fois reçus.
const memeFournisseur = rowsMeta?.key.startsWith(`${codeFournisseur}:`) === true && lignesEnMemoire.length > 0;
const accumulatedRows: ProductRow[] = [];
let lastFlush = 0;
setRows([]);
if (!memeFournisseur) {
setRows([]);
// Les lignes vont être remplacées : tant que ce chargement n'est pas
// terminé, elles ne correspondent plus à aucune clé.
setRowsMeta(null);
}
setRowsLoaded(0);
setTotalRows(null);
setLoadError(null);
@@ -130,10 +154,12 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
// Les lignes déjà poussées dans le store ne doivent plus bouger : on lui
// passe une copie, et on continue d'accumuler dans le tableau privé.
const flush = (loaded: number, total: number | null, final = false) => {
setRows(final ? accumulatedRows : accumulatedRows.slice());
setRowsLoaded(loaded);
setTotalRows(total);
lastFlush = Date.now();
// Même fournisseur : on garde les lignes affichées jusqu'au bout.
if (memeFournisseur && !final) return;
setRows(final ? accumulatedRows : accumulatedRows.slice());
};
async function loadRows() {
@@ -184,6 +210,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
}
flush(loaded, total, true);
setRowsMeta({ key: cle, loadedAt: Date.now() });
if (forceRefresh) servedRefreshRef.current = refreshRequest;
} catch (error) {
if (!controller.signal.aborted) {
@@ -265,7 +292,9 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
<div className="flex items-center justify-between gap-3 text-[13px] text-[var(--text-primary)]">
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-[var(--accent)]" />
Chargement des produits de {nomFournisseur}…
{nbRows > 0
? "Mise à jour des chiffres… la grille reste utilisable."
: `Chargement des produits de ${nomFournisseur}…`}
</span>
<span className="tabular-nums text-[var(--text-secondary)]">
{totalRows
@@ -18,7 +18,7 @@ interface GridFilterBarProps {
const EMPTY_DRAFT_CHANGES: Record<string, string> = {};
export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
function GridFilterBarInner({ fournisseurs, magasins }: GridFilterBarProps) {
const filters = useGridStore((s) => s.filters);
const setFilter = useGridStore((s) => s.setFilter);
const rows = useGridStore((s) => s.rows);
@@ -204,3 +204,9 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
</div>
);
}
/**
* Mémoïsé : ses props sont stables, il n'a donc pas à se redessiner quand le
* parent change d'état (sélection, progression du chargement…).
*/
export const GridFilterBar = React.memo(GridFilterBarInner);
+106 -76
View File
@@ -242,6 +242,100 @@ const GammeCell = React.memo(({ row, isAdmin }: { row: ProductRow; isAdmin?: boo
});
GammeCell.displayName = "GammeCell";
/**
* En-tête du tableau, mémoïsé : sans cela, chaque événement de défilement
* redessinait la trentaine d'en-têtes (tri, redimensionnement…). Il ne change
* qu'avec les colonnes, le tri ou les largeurs.
*/
const GridHeader = React.memo(function GridHeader({ table }: {
table: Table<ProductRow>;
// Les props suivantes ne sont pas lues directement : elles servent à la
// comparaison de React.memo (l'en-tête lit ces états via `table`).
columnsKey: string;
sorting: SortingState;
columnSizing: Record<string, number>;
/** Case « tout sélectionner ». */
rowSelection: RowSelectionState;
data: ProductRow[];
}) {
return (
<thead className="sticky top-0 z-10 block" style={{
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
borderBottom: "1px solid var(--border-strong)",
}}>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="flex w-full">
{(() => {
const figesEntete = decalagesFiges(
headerGroup.headers.map((h) => ({ id: h.column.id, taille: h.getSize() })),
);
const dernierFigeEntete = [...figesEntete.keys()].at(-1);
return headerGroup.headers.map((header) => {
const gaucheEntete = figesEntete.get(header.column.id);
const enteteFigee = gaucheEntete !== undefined;
const isFlexible = header.column.id === "libelle1" || header.column.id === "libelle3";
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(header.column.id) || header.column.id === "prixVente" || header.column.id === "gamme";
const size = header.getSize();
return (
<th
key={header.id}
className={cn(
"px-2 py-2 text-xs font-semibold leading-tight whitespace-nowrap select-none flex items-center transition-colors relative group/header",
!enteteFigee && "hover:bg-white/5",
)}
style={{
width: isFlexible ? "100%" : size,
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
minWidth: size,
maxWidth: isFlexible ? "none" : size,
color: "var(--text-secondary)",
justifyContent: isCenter ? "center" : "flex-start",
...(enteteFigee ? {
position: "sticky" as const,
left: gaucheEntete,
zIndex: 2,
// Le fond du <thead> est un dégradé translucide : sans
// fond propre, les autres intitulés défileraient à
// travers l'en-tête figé. On reprend le même dégradé
// pour que la bande reste d'un seul tenant.
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
borderRight: header.column.id === dernierFigeEntete
? "1px solid var(--border-strong)"
: undefined,
} : {}),
}}
onClick={header.column.getToggleSortingHandler()}
>
<div className={`flex items-center gap-1.5 cursor-pointer ${isCenter ? "justify-center w-full" : ""}`}>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && (
<div className="shrink-0 opacity-40">
{header.column.getIsSorted() === "asc" ? <ChevronUp className="w-3 h-3 text-emerald-500" />
: header.column.getIsSorted() === "desc" ? <ChevronDown className="w-3 h-3 text-emerald-500" />
: <ChevronsUpDown className="w-3 h-3" />
}
</div>
)}
</div>
{/* Handle de redimensionnement de la colonne */}
{header.column.getCanResize() && (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onClick={(e) => e.stopPropagation()}
className={`absolute right-0 top-0 h-full w-1.5 cursor-col-resize user-select-none touch-none hover:bg-emerald-500/50 ${header.column.getIsResizing() ? "bg-emerald-500" : ""}`}
/>
)}
</th>
);
});
})()}
</tr>
))}
</thead>
);
});
/**
* Libellés du menu « Colonnes ». Les en-têtes de colonnes sont des fonctions
* (deux lignes, infobulles) : le menu affichait leur identifiant technique
@@ -274,7 +368,7 @@ function libelleColonne(id: string, header: unknown): string {
return typeof header === "string" ? header : id;
}
import { Cell, Column, Row } from "@tanstack/react-table";
import { Cell, Column, Row, type Table } from "@tanstack/react-table";
import { VirtualItem } from "@tanstack/react-virtual";
// 2. Composant isolé pour la Ligne Virtuelle
@@ -605,7 +699,7 @@ function VentilationParMagasin({ d }: { d: CellDetailData }) {
// =========================================================================
export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
const rows = useGridStore((s) => s.rows);
const filters = useGridStore((s) => s.filters);
@@ -1159,6 +1253,9 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
const rowVirtualizer = useVirtualizer({
// Pas de rendu synchrone à chaque événement de défilement (flushSync) :
// React regroupe les mises à jour, le défilement reste fluide.
useFlushSync: false,
count: tableRows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => rowHeight,
@@ -1328,80 +1425,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
}}
>
<table className="text-sm block" style={{ width: "100%", minWidth: totalWidth }}>
<thead className="sticky top-0 z-10 block" style={{
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
borderBottom: "1px solid var(--border-strong)",
}}>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="flex w-full">
{(() => {
const figesEntete = decalagesFiges(
headerGroup.headers.map((h) => ({ id: h.column.id, taille: h.getSize() })),
);
const dernierFigeEntete = [...figesEntete.keys()].at(-1);
return headerGroup.headers.map((header) => {
const gaucheEntete = figesEntete.get(header.column.id);
const enteteFigee = gaucheEntete !== undefined;
const isFlexible = header.column.id === "libelle1" || header.column.id === "libelle3";
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || QLIK_NETWORK_COLUMN_IDS.has(header.column.id) || header.column.id === "prixVente" || header.column.id === "gamme";
const size = header.getSize();
return (
<th
key={header.id}
className={cn(
"px-2 py-2 text-xs font-semibold leading-tight whitespace-nowrap select-none flex items-center transition-colors relative group/header",
!enteteFigee && "hover:bg-white/5",
)}
style={{
width: isFlexible ? "100%" : size,
flex: isFlexible ? `1 1 ${size}px` : `0 0 ${size}px`,
minWidth: size,
maxWidth: isFlexible ? "none" : size,
color: "var(--text-secondary)",
justifyContent: isCenter ? "center" : "flex-start",
...(enteteFigee ? {
position: "sticky" as const,
left: gaucheEntete,
zIndex: 2,
// Le fond du <thead> est un dégradé translucide : sans
// fond propre, les autres intitulés défileraient à
// travers l'en-tête figé. On reprend le même dégradé
// pour que la bande reste d'un seul tenant.
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
borderRight: header.column.id === dernierFigeEntete
? "1px solid var(--border-strong)"
: undefined,
} : {}),
}}
onClick={header.column.getToggleSortingHandler()}
>
<div className={`flex items-center gap-1.5 cursor-pointer ${isCenter ? "justify-center w-full" : ""}`}>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && (
<div className="shrink-0 opacity-40">
{header.column.getIsSorted() === "asc" ? <ChevronUp className="w-3 h-3 text-emerald-500" />
: header.column.getIsSorted() === "desc" ? <ChevronDown className="w-3 h-3 text-emerald-500" />
: <ChevronsUpDown className="w-3 h-3" />
}
</div>
)}
</div>
{/* Handle de redimensionnement de la colonne */}
{header.column.getCanResize() && (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onClick={(e) => e.stopPropagation()}
className={`absolute right-0 top-0 h-full w-1.5 cursor-col-resize user-select-none touch-none hover:bg-emerald-500/50 ${header.column.getIsResizing() ? "bg-emerald-500" : ""}`}
/>
)}
</th>
);
});
})()}
</tr>
))}
</thead>
<GridHeader table={table} columnsKey={columnsKey} sorting={sorting} columnSizing={columnSizing} rowSelection={rowSelection} data={filteredData} />
{/*
* La clé porte AUSSI la signature des colonnes : changer de jeu de
* colonnes remonte le corps du tableau au lieu de compter sur la
@@ -1470,3 +1494,9 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
</>
);
}
/**
* Mémoïsé : ses props sont stables, il n'a donc pas à se redessiner quand le
* parent change d'état (sélection, progression du chargement…).
*/
export const HeatmapGrid = React.memo(HeatmapGridInner);
@@ -24,6 +24,13 @@ interface GridState {
*/
refreshRequest: number;
requestRefresh: () => void;
/**
* Ce que contiennent `rows` (fournisseur:magasin) et quand elles ont été
* chargées. Non persisté : sert à ne pas retélécharger la Grille quand on y
* revient, et à changer de magasin sans vider l'écran.
*/
rowsMeta: { key: string; loadedAt: number } | null;
setRowsMeta: (meta: { key: string; loadedAt: number } | null) => void;
/** Persisted column visibility state */
columnVisibility: Record<string, boolean>;
/** Persisted column sizing state */
@@ -135,6 +142,7 @@ export const useGridStore = create<GridState>()(
displayDensity: "normal",
activeGridQuery: "",
refreshRequest: 0,
rowsMeta: null,
columnVisibility: {},
columnSizing: {},
showMonthlySales: true,
@@ -222,6 +230,7 @@ export const useGridStore = create<GridState>()(
setDisplayDensity: (density) => set({ displayDensity: density }),
setActiveGridQuery: (query) => set({ activeGridQuery: sansParamRefresh(query) }),
requestRefresh: () => set((state) => ({ refreshRequest: state.refreshRequest + 1 })),
setRowsMeta: (meta) => set({ rowsMeta: meta }),
restoreSnapshot: (changes) => {
set({ draftChanges: changes, summary: computeSummary(get().rows, changes, get().activeMagasin) });
},
+13 -10
View File
@@ -95,18 +95,27 @@ export async function getProduitFiche(codein: string): Promise<ProduitFiche | nu
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([
// Tout part en même temps : l'identité du produit était lue seule d'abord,
// puis les cinq autres lectures, puis les métriques réseau — trois attentes
// successives. Les métriques réseau n'attendent que l'identité (code centrale).
const detailPromise = pgGetProduitDetail(cleaned);
const reseauPromise = detailPromise.then(async (d) => {
if (!d?.code_centrale) return null;
const map = await getNetworkMetricsByCodeCentrale([d.code_centrale]);
return map.get(d.code_centrale) ?? null;
});
const [detail, fournisseurs, stock, commandesEnCours, gammes, mensuelRows, reseau] = await Promise.all([
detailPromise,
pgGetFournisseursByCodein(cleaned),
pgGetStockByCodein(cleaned),
pgGetCommandesByCodein(cleaned),
pgGetGammeHistoryByCodein(cleaned),
pgGetMensuelByCodein(cleaned, dateDebut, dateFin),
reseauPromise,
]);
if (!detail) return null;
const months = buildMonthKeys();
const allowed = new Set(months);
@@ -179,12 +188,6 @@ export async function getProduitFiche(codein: string): Promise<ProduitFiche | nu
.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,
+12 -3
View File
@@ -31,6 +31,10 @@ export interface AuthContext {
subject: string;
}
/** Intervalle minimal entre deux mises à jour de `last_used_at` pour une même clé. */
const TRACE_USAGE_MS = 60_000;
const derniereTrace = new Map<number, number>();
/** Préfixe des clés CollectFlow, pour les reconnaître d'un coup d'œil. */
const KEY_PREFIX = "cf_";
@@ -93,9 +97,14 @@ export async function requireApiAuth(req: NextRequest): Promise<AuthContext | Re
return fail("unauthorized", "Clé d'API révoquée.");
}
// Trace d'usage — jamais bloquante pour la requête en cours.
void db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, row.id))
.catch((e) => console.error("[api-auth] lastUsedAt KO:", (e as Error).message?.slice(0, 120)));
// Trace d'usage — jamais bloquante, et au plus une écriture par minute et
// par clé : un script qui pagine ne déclenche plus un UPDATE par requête.
const maintenant = Date.now();
if (maintenant - (derniereTrace.get(row.id) ?? 0) >= TRACE_USAGE_MS) {
derniereTrace.set(row.id, maintenant);
void db.update(apiKeys).set({ lastUsedAt: new Date(maintenant) }).where(eq(apiKeys.id, row.id))
.catch((e) => console.error("[api-auth] lastUsedAt KO:", (e as Error).message?.slice(0, 120)));
}
return { via: "api_key", role: row.role, subject: row.name };
}
+55 -11
View File
@@ -61,6 +61,13 @@ export interface GridQueryResult {
computedAt: string | null;
}
/**
* Version du format des lignes persistées. À incrémenter dès qu'un champ de
* `ProductRow` est ajouté ou change de sens : les instantanés plus anciens ne
* seront plus servis à la Grille (recalcul), mais restent lisibles par /api/v1.
*/
export const GRID_PAYLOAD_VERSION = 1;
/**
* Persiste les lignes calculées pour un fournisseur.
*
@@ -97,7 +104,7 @@ export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[]
nbMagasinsReseau: r.nbMagasinsReseau ?? null,
caParMagasinReseau: r.caParMagasinReseau != null ? String(r.caParMagasinReseau) : null,
margePctReseau: r.margePctReseau != null ? String(r.margePctReseau) : null,
payload: r,
payload: { ...r, payloadVersion: GRID_PAYLOAD_VERSION },
computedAt,
}));
if (values.length === 0) return 0;
@@ -147,6 +154,39 @@ export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[]
return written;
}
/**
* Instantané complet d'un fournisseur, tel que persisté par le dernier calcul.
*
* Renvoie `null` (→ recalcul) si rien n'est persisté, si une ligne est d'une
* version de format antérieure, ou si le calcul date d'un autre mois que le mois
* en cours : la fenêtre des 12 mois glissants aurait changé entre-temps.
* Les lignes sont rendues dans l'ordre du calcul en direct (identifiant article).
*/
export async function readGridSnapshot(
codeFournisseur: string,
): Promise<{ rows: ProductRow[]; computedAt: Date } | null> {
const found = await db
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
.from(gridRows)
.where(eq(gridRows.codeFournisseur, codeFournisseur));
if (found.length === 0) return null;
let oldest: Date | null = null;
const rows: ProductRow[] = [];
for (const r of found) {
const row = r.payload as ProductRow;
if (row.payloadVersion !== GRID_PAYLOAD_VERSION) return null;
if (!oldest || r.computedAt < oldest) oldest = r.computedAt;
rows.push(row);
}
const now = new Date();
if (!oldest || oldest.getFullYear() !== now.getFullYear() || oldest.getMonth() !== now.getMonth()) {
return null;
}
rows.sort((a, b) => (a.noid ?? 0) - (b.noid ?? 0));
return { rows, computedAt: oldest };
}
/**
* Reporte des gammes enregistrées dans l'instantané persisté (colonne et payload),
* pour que /api/v1 les voie sans attendre un recalcul complet de la grille.
@@ -221,20 +261,17 @@ export async function queryGridRows(q: GridQuery): Promise<GridQueryResult> {
// NULLS LAST dans les deux sens : un produit sans CA ne doit jamais occuper la tête.
const orderBy = q.order === "asc" ? asc(sortCol) : desc(sortCol);
const [countRow] = await db
.select({ total: sql<number>`count(*)::int` })
.from(gridRows)
.where(where);
const selection = db
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
.from(gridRows)
.where(where)
.orderBy(orderBy, asc(gridRows.codein));
const found = limit != null
? await selection.limit(limit).offset((page - 1) * limit)
: await selection;
// Comptage et page en parallèle : deux connexions du pool, un seul temps d'attente.
const [[countRow], found] = await Promise.all([
db.select({ total: sql<number>`count(*)::int` }).from(gridRows).where(where),
limit != null ? selection.limit(limit).offset((page - 1) * limit) : selection,
]);
let oldest: Date | null = null;
for (const r of found) {
@@ -242,12 +279,19 @@ export async function queryGridRows(q: GridQuery): Promise<GridQueryResult> {
}
return {
rows: found.map((r) => r.payload as ProductRow),
rows: found.map((r) => versLigneApi(r.payload)),
total: countRow?.total ?? 0,
computedAt: oldest ? oldest.toISOString() : null,
};
}
/** Ligne exposée par l'API : la version de format est un détail interne. */
function versLigneApi(payload: unknown): ProductRow {
const ligne = { ...(payload as ProductRow) };
delete ligne.payloadVersion;
return ligne;
}
/** Fiche complète d'un produit. `codeFournisseur` lève l'ambiguïté d'un article multi-fournisseurs. */
export async function getGridRowByCodein(
codein: string,
@@ -265,7 +309,7 @@ export async function getGridRowByCodein(
if (!found) return null;
return {
row: found.payload as ProductRow,
row: versLigneApi(found.payload),
computedAt: found.computedAt ? found.computedAt.toISOString() : null,
};
}
+29 -5
View File
@@ -674,8 +674,11 @@ export async function pgGetDashboardData(): Promise<DashboardData> {
if (allCodeins.length > 0) {
try {
for (let i = 0; i < allCodeins.length; i += 5) {
const batch = allCodeins.slice(i, i + 5);
// Vagues de 12 articles au lieu de 5 : chaque article coûte deux allers-
// retours successifs, et la page d'accueil attendait une douzaine de vagues.
const VAGUE = 12;
for (let i = 0; i < allCodeins.length; i += VAGUE) {
const batch = allCodeins.slice(i, i + VAGUE);
await Promise.all(batch.map(async (codein) => {
try {
const artRes = await fetch(`${FF_API_BASE}/api/articles?codein=${encodeURIComponent(codein)}`, ffFetchInit());
@@ -816,6 +819,27 @@ export interface CaByNomenclatureRow {
* via une sous-requête artfou1 dédupliquée (DISTINCT ON) : un seul code par
* article, aucune duplication possible des mouvements dans la somme.
*/
/**
* Bornes d'un mois « YYYY-MM » : [premier jour, premier jour du mois suivant[.
*
* Filtrer `datmvt` sur ces bornes plutôt que sur `TO_CHAR(datmvt, 'YYYY-MM')`
* donne exactement les mêmes lignes, mais sans formater en texte la date de
* chaque mouvement de l'historique (et reste utilisable par un index sur datmvt).
*/
function bornesMois(mois: string): { debut: string; fin: string } {
const [annee, m] = mois.split("-").map(Number);
const suivant = m === 12 ? `${annee + 1}-01` : `${annee}-${String(m + 1).padStart(2, "0")}`;
return { debut: `${mois}-01`, fin: `${suivant}-01` };
}
/** Mouvements du mois OU du mois N-1 (cf. `bornesMois`). */
function filtreDeuxMois(mois: string, moisN1: string): SQL {
const a = bornesMois(mois);
const b = bornesMois(moisN1);
return sql`((m.datmvt >= ${a.debut}::date AND m.datmvt < ${a.fin}::date)
OR (m.datmvt >= ${b.debut}::date AND m.datmvt < ${b.fin}::date))`;
}
export async function pgGetCaByFournisseur(
mois: string,
moisN1: string
@@ -836,7 +860,7 @@ export async function pgGetCaByFournisseur(
ORDER BY art_no_id, code
) af ON af.art_no_id = a.no_id
LEFT JOIN fouident fi ON fi.code = af.code
WHERE (TO_CHAR(m.datmvt, 'YYYY-MM') = ${mois} OR TO_CHAR(m.datmvt, 'YYYY-MM') = ${moisN1})
WHERE ${filtreDeuxMois(mois, moisN1)}
AND m.site IN ('292', '579')
AND m.genremvt = 3
GROUP BY af.code, fi.nom, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
@@ -865,7 +889,7 @@ export async function pgGetCaByNomenclature(
FROM mvtart m
JOIN articles a ON a.no_id = m.artnoid
LEFT JOIN nomenclature n ON n.no_id = a.nom_no_id
WHERE (TO_CHAR(m.datmvt, 'YYYY-MM') = ${mois} OR TO_CHAR(m.datmvt, 'YYYY-MM') = ${moisN1})
WHERE ${filtreDeuxMois(mois, moisN1)}
AND m.site IN ('292', '579')
AND m.genremvt = 3
GROUP BY n.no_id, n.code, n.libelle, m.site, TO_CHAR(m.datmvt, 'YYYY-MM')
@@ -1211,7 +1235,7 @@ export async function pgGetStockSansVente(site?: string): Promise<PgStockSansVen
${lastEntryJoin(link, sql`a.no_id`, sql`m.site`)}
WHERE m.genremvt IN (1, 2)
AND m.site IN ('292', '579')
AND date_trunc('month', m.datmvt) < date_trunc('month', CURRENT_DATE)
AND m.datmvt < date_trunc('month', CURRENT_DATE)
${siteFilter}
AND NOT EXISTS (
SELECT 1 FROM mvtart mv2
+6
View File
@@ -145,6 +145,12 @@ export interface ProductRow {
nbMagReseauByMonth?: Record<string, number> | null;
/** Fraîcheur des données réseau (ISO) */
networkFetchedAt?: string;
/**
* Version du format de la ligne, posée à l'écriture dans `grid_rows`. Une
* ligne d'une version antérieure n'est pas servie telle quelle à la Grille
* (des champs récents lui manqueraient) : elle est recalculée.
*/
payloadVersion?: number;
}
/** Summary bar totals for the currently visible/filtered rows */