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

+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,
};
}