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

+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 */