mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-22 22:48:08 +02:00
fix(grille): grille vide au changement de magasin, et stock TOTAL incomplet
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
3 files changed
+71
-6
No files matched your search
@@ -288,6 +288,25 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
|||||||
product.caByStore[site] = sCa;
|
product.caByStore[site] = sCa;
|
||||||
product.margeByStore[site] = sMarge;
|
product.margeByStore[site] = sMarge;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Le stock TOTAL se recalcule ici, à partir des séries par site.
|
||||||
|
//
|
||||||
|
// Il était sommé plus haut depuis les lignes mensuelles brutes, ce
|
||||||
|
// qui perdait tout magasin SANS MOUVEMENT dans le mois : le stock
|
||||||
|
// est un niveau, pas un flux — un magasin qui n'a rien vendu ni reçu
|
||||||
|
// détient toujours sa marchandise. Sur un produit à faible rotation,
|
||||||
|
// « tous magasins » n'affichait donc que le site actif, en pratique
|
||||||
|
// Frouard. Les séries par site, elles, sont reportées d'un mois sur
|
||||||
|
// l'autre : leur somme est la bonne.
|
||||||
|
const sites = Object.keys(product.stock12mByStore);
|
||||||
|
if (sites.length > 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
|
// workingStores : sites avec au moins 1 vente sur les 12 derniers mois
|
||||||
@@ -396,7 +415,14 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[getProductRows] Error for ${codeFournisseur}:`, error);
|
console.error(`[getProductRows] Error for ${codeFournisseur}:`, error);
|
||||||
return [];
|
// Ne PAS renvoyer [] : 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 alors automatiquement. La remonter laisse chaque appelant la
|
||||||
|
// traiter comme une erreur (bandeau rouge, statut « echec »).
|
||||||
|
throw error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(`Calcul de la grille impossible pour ${codeFournisseur} : ${String(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,9 +443,22 @@ async function reconcileSelectedStoreFromMensuelApi(
|
|||||||
|
|
||||||
if (candidates.length === 0) return;
|
if (candidates.length === 0) return;
|
||||||
|
|
||||||
|
// Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur,
|
||||||
|
// les candidats se comptent par milliers et le changement de magasin se fige
|
||||||
|
// plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté
|
||||||
|
// plutôt que de tronquer en silence.
|
||||||
|
const PLAFOND = Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300);
|
||||||
|
const retenus = candidates.slice(0, PLAFOND);
|
||||||
|
if (candidates.length > retenus.length) {
|
||||||
|
console.warn(
|
||||||
|
`[getProductRows] rattrapage magasin ${magasin} borné à ${retenus.length} articles`
|
||||||
|
+ ` sur ${candidates.length} : les autres gardent la valeur SQL.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mensuelMap = await getMensuelByArticles(
|
const mensuelMap = await getMensuelByArticles(
|
||||||
candidates.map((row) => ({
|
retenus.map((row) => ({
|
||||||
codein: row.codein,
|
codein: row.codein,
|
||||||
libelle1: row.libelle1,
|
libelle1: row.libelle1,
|
||||||
codefou: row.codeFournisseur,
|
codefou: row.codeFournisseur,
|
||||||
@@ -431,7 +470,7 @@ async function reconcileSelectedStoreFromMensuelApi(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let fixedRows = 0;
|
let fixedRows = 0;
|
||||||
for (const row of candidates) {
|
for (const row of retenus) {
|
||||||
const entries = (mensuelMap.get(row.codein) ?? []).filter((entry) => entry.site === magasin);
|
const entries = (mensuelMap.get(row.codein) ?? []).filter((entry) => entry.site === magasin);
|
||||||
if (entries.length === 0) continue;
|
if (entries.length === 0) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -413,10 +413,14 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
|||||||
// Filtre client-side par code3 (famille) et codeGamme
|
// Filtre client-side par code3 (famille) et codeGamme
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
const { code3, codeGamme } = filters;
|
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
|
// Set plutôt que includes() : la liste peut compter des dizaines de
|
||||||
// nomenclatures, et le filtre est réévalué pour chaque ligne.
|
// 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 => {
|
return rows.filter(r => {
|
||||||
if (nomenclatures && !nomenclatures.has(r.code3)) return false;
|
if (nomenclatures && !nomenclatures.has(r.code3)) return false;
|
||||||
if (codeGamme) {
|
if (codeGamme) {
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ function computeSummary(rows: ProductRow[], drafts: Record<string, GammeCode>, 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<GridState>()(
|
export const useGridStore = create<GridState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
@@ -219,8 +225,24 @@ export const useGridStore = create<GridState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "collectflow-grid-storage",
|
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<string, unknown> };
|
||||||
|
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
|
// Only persist filters, display density, drafts, active grid query, and column visibility/sizing
|
||||||
partialize: (state) => ({
|
partialize: (state): GridPersisted => ({
|
||||||
filters: state.filters,
|
filters: state.filters,
|
||||||
displayDensity: state.displayDensity,
|
displayDensity: state.displayDensity,
|
||||||
draftChanges: state.draftChanges,
|
draftChanges: state.draftChanges,
|
||||||
|
|||||||
Reference in new issue
Block a user