From 6e59458403bd652ccab946444856887494013c4b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 09:04:09 +0000 Subject: [PATCH] fix(grille): grille vide au changement de magasin, et stock TOTAL incomplet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois défauts distincts, dont un que je venais d'introduire. 1. RÉGRESSION — grille vide. Le store zustand est persisté dans le navigateur sans version ni migration. Le passage de filters.code3 à `string[] | null` laissait donc une CHAÎNE dans le localStorage des utilisateurs : `new Set("320211")` produit un ensemble de caractères, plus aucune ligne ne correspond, et la Grille apparaît vide sans le moindre message. Ajout de `version: 1` + `migrate`, plus une garde dans le filtre pour ne jamais redevenir muet sur un état inattendu. 2. STOCK « tous magasins » incomplet. Le stock est un NIVEAU, pas un flux : un magasin sans mouvement dans le mois détient toujours sa marchandise. Or le TOTAL était sommé depuis les lignes mensuelles brutes, donc n'incluait que les sites ayant bougé ce mois-là — sur un produit à faible rotation, il n'affichait que Frouard. Il est désormais recalculé depuis les séries par site, qui sont reportées d'un mois sur l'autre. 3. CHANGEMENT DE MAGASIN qui se fige. Hors « tous magasins », un rattrapage interroge l'API FF à raison d'UNE requête HTTP par article : sur un gros fournisseur, cela fait des milliers d'appels. Borné à 300 (réglable via GRID_STORE_RECONCILE_MAX), avec un avertissement explicite sur ce qui n'a pas été rattrapé — pas de troncature silencieuse. Enfin, getProductRows ne renvoie plus [] en cas d'erreur : une liste vide est indiscernable d'un fournisseur sans article. La Grille affichait une page blanche sans explication, et la synchro nocturne prenait la panne pour un fournisseur vide — qu'elle désactivait automatiquement. L'erreur remonte désormais jusqu'au bandeau rouge et au statut « echec ». Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675 --- src/features/grid/api/get-product-rows.ts | 45 +++++++++++++++++-- src/features/grid/components/heatmap-grid.tsx | 8 +++- src/features/grid/store/use-grid-store.ts | 24 +++++++++- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 66c5287..477f741 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -288,6 +288,25 @@ async function buildProductRows(input: GetProductRowsInput): Promise 0) { + for (const periode of sortedPeriods) { + product.stock12m[periode] = sites.reduce( + (total, site) => total + (product.stock12mByStore![site][periode] ?? 0), + 0, + ); + } + } } // workingStores : sites avec au moins 1 vente sur les 12 derniers mois @@ -396,7 +415,14 @@ async function buildProductRows(input: GetProductRowsInput): Promise retenus.length) { + console.warn( + `[getProductRows] rattrapage magasin ${magasin} borné à ${retenus.length} articles` + + ` sur ${candidates.length} : les autres gardent la valeur SQL.`, + ); + } + try { const mensuelMap = await getMensuelByArticles( - candidates.map((row) => ({ + retenus.map((row) => ({ codein: row.codein, libelle1: row.libelle1, codefou: row.codeFournisseur, @@ -431,7 +470,7 @@ async function reconcileSelectedStoreFromMensuelApi( ); let fixedRows = 0; - for (const row of candidates) { + for (const row of retenus) { const entries = (mensuelMap.get(row.codein) ?? []).filter((entry) => entry.site === magasin); if (entries.length === 0) continue; diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 0a75f15..85ccc0e 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -413,10 +413,14 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { // Filtre client-side par code3 (famille) et codeGamme const filteredData = useMemo(() => { const { code3, codeGamme } = filters; - if (!code3 && !codeGamme) return rows; + // Garde : un état persisté d'une version antérieure peut encore contenir + // une chaîne. La migration du store le corrige, mais un `new Set("320211")` + // produirait un ensemble de caractères et viderait la Grille sans rien dire. + const codes = Array.isArray(code3) ? code3 : typeof code3 === "string" ? [code3] : null; + if (!codes && !codeGamme) return rows; // Set plutôt que includes() : la liste peut compter des dizaines de // nomenclatures, et le filtre est réévalué pour chaque ligne. - const nomenclatures = code3 ? new Set(code3) : null; + const nomenclatures = codes ? new Set(codes) : null; return rows.filter(r => { if (nomenclatures && !nomenclatures.has(r.code3)) return false; if (codeGamme) { diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index 5449acc..82977d4 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -72,6 +72,12 @@ function computeSummary(rows: ProductRow[], drafts: Record, m }; } +/** Sous-ensemble réellement écrit dans le navigateur (cf. `partialize`). */ +type GridPersisted = Pick< + GridState, + "filters" | "displayDensity" | "draftChanges" | "activeGridQuery" | "columnVisibility" | "columnSizing" +>; + export const useGridStore = create()( persist( (set, get) => ({ @@ -219,8 +225,24 @@ export const useGridStore = create()( }), { name: "collectflow-grid-storage", + /** + * v1 — `filters.code3` est passé d'une chaîne unique à `string[] | null` + * (filtre multi-nomenclatures). Sans migration, la valeur déjà présente + * dans le navigateur restait une chaîne : `new Set("320211")` produit un + * ensemble de caractères, plus aucune ligne ne correspond, et la Grille + * apparaît vide. Le numéro de version force la reprise de l'existant. + */ + version: 1, + migrate: (persisted: unknown, version: number): GridPersisted => { + const etat = (persisted ?? {}) as GridPersisted & { filters?: Record }; + if (version < 1 && etat.filters) { + const c = etat.filters.code3; + etat.filters.code3 = typeof c === "string" && c !== "" ? [c] : Array.isArray(c) ? c : null; + } + return etat; + }, // Only persist filters, display density, drafts, active grid query, and column visibility/sizing - partialize: (state) => ({ + partialize: (state): GridPersisted => ({ filters: state.filters, displayDensity: state.displayDensity, draftChanges: state.draftChanges,