mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-19 13:08:11 +02:00
Lot 5 : changement de magasin instantané et contrôle des droits
Grille - Corrige le lot 4 : au changement de magasin, les chiffres et le tri affichés suivent tout de suite (nouvelle liste), la sélection est gardée. - Lignes toujours chargées « Nos 2 magasins » ; changement de magasin sans aller-retour serveur (URL mise à jour par l'historique du navigateur). - Complément de l'API FF par magasin servi à part (/api/grid/rows/store-patch), appliqué à son arrivée, calcul identique à l'ancien (lib/store-patch.ts). - La recherche ne reconstruit plus toutes les lignes à chaque frappe ; la sélection n'est remise à zéro qu'au changement de fournisseur. Sécurité - lib/authz.ts : session relue en base (cache 1 min), rôle de la base prioritaire ; variantes pour actions, routes et pages. - Paramètres réservés aux administrateurs ; session vérifiée dans les actions Grille, Commandes, Historique et les routes ; diagnostics réservés admin. - Mots de passe et clés d'IA plus envoyés au navigateur ; champ vide = mot de passe conservé ; plus de mot de passe dans le localStorage ; l'enregistrement de la base n'efface plus les clés d'IA. - admin/admin créé seulement si aucun utilisateur ; fichier de secours utilisé uniquement si la base est injoignable ; data/ ignoré par git. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
44 files changed
+1155
-531
No files matched your search
@@ -3,12 +3,14 @@
|
||||
import { getSitesFromApi } from "@/lib/api-ff-client";
|
||||
import { getFournisseursCached } from "@/lib/ff-cache";
|
||||
import { getProductRows } from "./api/get-product-rows";
|
||||
import { requireSession } from "@/lib/authz";
|
||||
import type { ProductRow, GridFilters } from "@/types/grid";
|
||||
|
||||
/**
|
||||
* Get the list of all suppliers from PostgreSQL (fouadr1).
|
||||
*/
|
||||
export async function getFournisseurs() {
|
||||
await requireSession();
|
||||
// Relu à chaque navigation dans la Grille (changement de fournisseur, de
|
||||
// magasin…) : le référentiel ne change qu'avec la recopie nocturne.
|
||||
return getFournisseursCached();
|
||||
@@ -18,6 +20,7 @@ export async function getFournisseurs() {
|
||||
* Get the list of all stores (sites) from the FF Nancy API.
|
||||
*/
|
||||
export async function getMagasins() {
|
||||
await requireSession();
|
||||
return getSitesFromApi();
|
||||
}
|
||||
|
||||
@@ -26,6 +29,7 @@ export async function getMagasins() {
|
||||
* Returns an empty hierarchy so the sidebar filter is hidden gracefully.
|
||||
*/
|
||||
export async function getAvailableNomenclature() {
|
||||
await requireSession();
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -37,5 +41,6 @@ export async function getGridData(
|
||||
magasin: string = "TOTAL",
|
||||
filters?: Partial<GridFilters>
|
||||
): Promise<ProductRow[]> {
|
||||
await requireSession();
|
||||
return getProductRows({ codeFournisseur, magasin, filters });
|
||||
}
|
||||
@@ -19,6 +19,13 @@ import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib
|
||||
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 { readGridSnapshot, upsertGridRows } from "@/lib/grid-store";
|
||||
import {
|
||||
applyStorePatchInPlace,
|
||||
buildStorePatchEntries,
|
||||
storePatchCandidates,
|
||||
type StorePatch,
|
||||
type StorePatchEntry,
|
||||
} from "@/features/grid/lib/store-patch";
|
||||
|
||||
interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
@@ -568,28 +575,27 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileSelectedStoreFromMensuelApi(
|
||||
/** Articles complétés par l'API FF, au plus, lors d'un changement de magasin. */
|
||||
function plafondComplement(): number {
|
||||
return Number(process.env.GRID_STORE_RECONCILE_MAX ?? 300);
|
||||
}
|
||||
|
||||
/** Interroge l'API FF pour les articles du magasin à compléter (cf. store-patch). */
|
||||
async function fetchStorePatchEntries(
|
||||
rows: ProductRow[],
|
||||
magasin: string,
|
||||
dateDebut: string,
|
||||
dateFin: string,
|
||||
sortedPeriods: string[]
|
||||
) {
|
||||
if (magasin === "TOTAL") return;
|
||||
|
||||
const candidates = rows.filter((row) => {
|
||||
if (!row.noid) return false;
|
||||
const storeQty = row.quantiteByStore?.[magasin] ?? 0;
|
||||
return storeQty === 0;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) return;
|
||||
periods: string[],
|
||||
): Promise<StorePatchEntry[]> {
|
||||
const candidates = storePatchCandidates(rows, magasin);
|
||||
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 PLAFOND = plafondComplement();
|
||||
const retenus = candidates.slice(0, PLAFOND);
|
||||
if (candidates.length > retenus.length) {
|
||||
console.warn(
|
||||
@@ -598,88 +604,31 @@ async function reconcileSelectedStoreFromMensuelApi(
|
||||
);
|
||||
}
|
||||
|
||||
const mensuelMap = await getMensuelByArticles(
|
||||
retenus.map((row) => ({
|
||||
codein: row.codein,
|
||||
libelle1: row.libelle1,
|
||||
codefou: row.codeFournisseur,
|
||||
noid: row.noid,
|
||||
})),
|
||||
dateDebut,
|
||||
dateFin,
|
||||
25
|
||||
);
|
||||
return buildStorePatchEntries(retenus, magasin, periods, mensuelMap);
|
||||
}
|
||||
|
||||
async function reconcileSelectedStoreFromMensuelApi(
|
||||
rows: ProductRow[],
|
||||
magasin: string,
|
||||
dateDebut: string,
|
||||
dateFin: string,
|
||||
sortedPeriods: string[]
|
||||
) {
|
||||
if (magasin === "TOTAL") return;
|
||||
try {
|
||||
const mensuelMap = await getMensuelByArticles(
|
||||
retenus.map((row) => ({
|
||||
codein: row.codein,
|
||||
libelle1: row.libelle1,
|
||||
codefou: row.codeFournisseur,
|
||||
noid: row.noid,
|
||||
})),
|
||||
dateDebut,
|
||||
dateFin,
|
||||
25
|
||||
);
|
||||
|
||||
let fixedRows = 0;
|
||||
for (const row of retenus) {
|
||||
const entries = (mensuelMap.get(row.codein) ?? []).filter((entry) => entry.site === magasin);
|
||||
if (entries.length === 0) continue;
|
||||
|
||||
const byPeriod = new Map<string, { qty: number; ca: number; marge: number; stock: number; receptions: number }>();
|
||||
for (const entry of entries) {
|
||||
const period = entry.mois.replace("-", "");
|
||||
if (!sortedPeriods.includes(period)) continue;
|
||||
// qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
|
||||
// les nie au lieu de Math.abs pour que les retours restent déduits.
|
||||
const qty = -(Number(entry.ventes?.qte_vendue ?? 0) || 0);
|
||||
const ca = -(Number(entry.ventes?.ca_ht ?? 0) || 0);
|
||||
const marge = Number(entry.ventes?.marge ?? 0) || 0;
|
||||
const stock = Number(entry.stock_fin_mois ?? 0) || 0;
|
||||
const receptions = Number(entry.receptions?.qte_recue ?? 0) || 0;
|
||||
byPeriod.set(period, { qty, ca, marge, stock, receptions });
|
||||
}
|
||||
|
||||
const apiQty = [...byPeriod.values()].reduce((sum, value) => sum + value.qty, 0);
|
||||
if (apiQty === 0) continue;
|
||||
|
||||
row.sales12mByStore ??= {};
|
||||
row.stock12mByStore ??= {};
|
||||
row.receptions12mByStore ??= {};
|
||||
row.caByStore ??= {};
|
||||
row.quantiteByStore ??= {};
|
||||
row.margeByStore ??= {};
|
||||
row.sales12mByStore[magasin] ??= {};
|
||||
row.stock12mByStore[magasin] ??= {};
|
||||
row.receptions12mByStore[magasin] ??= {};
|
||||
|
||||
let storeQty = 0;
|
||||
let storeCa = 0;
|
||||
let storeMarge = 0;
|
||||
let lastStock = 0;
|
||||
|
||||
for (const period of sortedPeriods) {
|
||||
const value = byPeriod.get(period);
|
||||
const currentQty = row.sales12mByStore[magasin][period] ?? 0;
|
||||
const nextQty = value?.qty ?? 0;
|
||||
const deltaQty = nextQty - currentQty;
|
||||
|
||||
row.sales12mByStore[magasin][period] = nextQty;
|
||||
row.receptions12mByStore[magasin][period] = value?.receptions ?? 0;
|
||||
if (value) lastStock = value.stock;
|
||||
row.stock12mByStore[magasin][period] = lastStock;
|
||||
|
||||
row.sales12m[period] = (row.sales12m[period] ?? 0) + deltaQty;
|
||||
storeQty += nextQty;
|
||||
storeCa += value?.ca ?? 0;
|
||||
storeMarge += value?.marge ?? 0;
|
||||
}
|
||||
|
||||
const deltaTotalQty = storeQty - (row.quantiteByStore[magasin] ?? 0);
|
||||
const deltaTotalCa = storeCa - (row.caByStore[magasin] ?? 0);
|
||||
const deltaTotalMarge = storeMarge - (row.margeByStore[magasin] ?? 0);
|
||||
|
||||
row.quantiteByStore[magasin] = storeQty;
|
||||
row.caByStore[magasin] = storeCa;
|
||||
row.margeByStore[magasin] = storeMarge;
|
||||
row.totalQuantite += deltaTotalQty;
|
||||
row.totalCa += deltaTotalCa;
|
||||
row.totalMarge += deltaTotalMarge;
|
||||
row.tauxMarge = row.totalCa > 0 ? (row.totalMarge / row.totalCa) * 100 : 0;
|
||||
if (!row.workingStores.includes(magasin)) row.workingStores.push(magasin);
|
||||
fixedRows++;
|
||||
}
|
||||
|
||||
const entries = await fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, sortedPeriods);
|
||||
const fixedRows = applyStorePatchInPlace(rows, magasin, sortedPeriods, entries);
|
||||
if (fixedRows > 0) {
|
||||
console.log(`[getProductRows] ${fixedRows} produits corrigés via API mensuelle pour magasin ${magasin}`);
|
||||
}
|
||||
@@ -688,6 +637,46 @@ async function reconcileSelectedStoreFromMensuelApi(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compléments par magasin, rattachés au tableau de lignes « tous magasins » en
|
||||
* cache : ils vivent et meurent avec lui (expiration, « Actualiser », nouvel
|
||||
* enregistrement), sans autre invalidation à gérer.
|
||||
*/
|
||||
const storePatches = new WeakMap<ProductRow[], Map<string, Promise<StorePatch>>>();
|
||||
|
||||
/**
|
||||
* Complément de l'API FF pour un magasin, calculé sur les lignes « tous
|
||||
* magasins » (jamais modifiées ici). La Grille l'applique à son arrivée, sans
|
||||
* recharger les lignes. Les demandes simultanées partagent le même calcul, et
|
||||
* un échec n'est jamais gardé.
|
||||
*/
|
||||
export async function getStorePatch(codeFournisseur: string, magasin: string): Promise<StorePatch> {
|
||||
const enCache = gridRowsCache.get(`${codeFournisseur}:TOTAL`);
|
||||
const rows = enCache && Date.now() - enCache.createdAt < GRID_ROWS_CACHE_TTL_MS
|
||||
? enCache.rows
|
||||
: await getProductRows({ codeFournisseur, magasin: "TOTAL" });
|
||||
|
||||
let parMagasin = storePatches.get(rows);
|
||||
if (!parMagasin) {
|
||||
parMagasin = new Map();
|
||||
storePatches.set(rows, parMagasin);
|
||||
}
|
||||
let patch = parMagasin.get(magasin);
|
||||
if (!patch) {
|
||||
const periods = last12Periods();
|
||||
const { dateDebut, dateFin } = buildLast12MonthsRange();
|
||||
const enCours = fetchStorePatchEntries(rows, magasin, dateDebut, dateFin, periods)
|
||||
.then((entries): StorePatch => ({ magasin, periods, entries }));
|
||||
const table = parMagasin;
|
||||
enCours.catch(() => {
|
||||
if (table.get(magasin) === enCours) table.delete(magasin);
|
||||
});
|
||||
parMagasin.set(magasin, enCours);
|
||||
patch = enCours;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la série « nombre de magasins vendeurs » du détail mensuel complet.
|
||||
*
|
||||
|
||||
@@ -4,7 +4,7 @@ import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { verifierSession } from "@/lib/authz";
|
||||
import { patchGridRowsCache } from "./get-product-rows";
|
||||
import { updateGridRowsGamme } from "@/lib/grid-store";
|
||||
|
||||
@@ -24,6 +24,9 @@ const SaveDraftsSchema = z.object({
|
||||
export async function saveDraftChanges(
|
||||
raw: unknown
|
||||
): Promise<{ success: boolean; saved: number; error?: string }> {
|
||||
const acces = await verifierSession();
|
||||
if (!acces.ok) return { success: false, saved: 0, error: acces.message };
|
||||
|
||||
const parsed = SaveDraftsSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return { success: false, saved: 0, error: "Validation failed: " + parsed.error.message };
|
||||
@@ -31,9 +34,8 @@ export async function saveDraftChanges(
|
||||
|
||||
const { codeFournisseur, nomFournisseur, magasin, changes } = parsed.data;
|
||||
|
||||
const session = await auth();
|
||||
const rawUserId = (session?.user as { id?: string | number })?.id;
|
||||
const userId = rawUserId ? parseInt(String(rawUserId), 10) : null;
|
||||
const rawUserId = acces.utilisateur.id;
|
||||
const userId = rawUserId ? parseInt(rawUserId, 10) : null;
|
||||
const finalUserId = userId && !isNaN(userId) ? userId : null;
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { HeatmapGrid } from "@/features/grid/components/heatmap-grid";
|
||||
import { FloatingSummaryBar } from "@/features/grid/components/floating-summary-bar";
|
||||
import { BulkActionToolbar } from "@/features/grid/components/bulk-action-toolbar";
|
||||
import { GridFilterBar } from "@/features/grid/components/grid-filter-bar";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useStorePatch } from "@/features/grid/hooks/use-store-patch";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import { AlertCircle, Loader2, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -61,6 +62,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const setFilter = useGridStore((s) => s.setFilter);
|
||||
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
|
||||
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
|
||||
@@ -107,18 +109,21 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const controller = new AbortController();
|
||||
const params = new URLSearchParams();
|
||||
params.set("fournisseur", codeFournisseur);
|
||||
params.set("magasin", magasin || "TOTAL");
|
||||
// Toujours « tous magasins » : les lignes portent le détail de chaque
|
||||
// magasin, le changement de magasin se fait donc sans rien recharger
|
||||
// (les compléments de l'API FF arrivent à part, cf. useStorePatch).
|
||||
params.set("magasin", "TOTAL");
|
||||
// Les filtres de nomenclature (code1/code2/code3) ne sont pas transmis : le
|
||||
// serveur ne s'en sert pas et la Grille les applique en local. Les faire
|
||||
// voyager relançait un chargement complet à chaque changement.
|
||||
const forceRefresh = refreshRequest !== servedRefreshRef.current;
|
||||
if (forceRefresh) params.set("refresh", "1");
|
||||
|
||||
const cle = `${codeFournisseur}:${magasin || "TOTAL"}`;
|
||||
const cle = rowsKeyFor(codeFournisseur);
|
||||
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.
|
||||
// Retour sur la Grille (même fournisseur, 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);
|
||||
@@ -127,11 +132,9 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
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;
|
||||
// « Actualiser » (ou lignes de plus de 10 min) sur le même fournisseur : la
|
||||
// Grille reste affichée, et les nouvelles lignes la remplacent d'un bloc.
|
||||
const memeFournisseur = rowsMeta?.key === cle && lignesEnMemoire.length > 0;
|
||||
|
||||
const accumulatedRows: ProductRow[] = [];
|
||||
let lastFlush = 0;
|
||||
@@ -210,7 +213,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
}
|
||||
|
||||
flush(loaded, total, true);
|
||||
setRowsMeta({ key: cle, loadedAt: Date.now() });
|
||||
setRowsMeta({ key: cle, loadedAt: Date.now(), patchedStores: [] });
|
||||
if (forceRefresh) servedRefreshRef.current = refreshRequest;
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
@@ -226,9 +229,13 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
loadRows();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [codeFournisseur, magasin, refreshRequest, setRows, isMounted]);
|
||||
}, [codeFournisseur, refreshRequest, setRows, isMounted]);
|
||||
|
||||
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
|
||||
// Compléments de l'API FF pour le magasin choisi, appliqués à leur arrivée.
|
||||
useStorePatch(codeFournisseur, isMounted);
|
||||
|
||||
// Magasin initial (ou porté par un lien) : la prop vient de l'URL. Les
|
||||
// changements suivants passent par le store et ne re-rendent pas la page.
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
setActiveMagasin(magasin || "TOTAL");
|
||||
@@ -259,7 +266,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
</h1>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-2 pl-4 shrink-0 border-l border-[var(--border)]">
|
||||
<MetricPill label="Magasin" value={nomMagasin(magasin)} />
|
||||
<MetricPill label="Magasin" value={nomMagasin(activeMagasin)} />
|
||||
<MetricPill label="Produits" value={nbReferences.toLocaleString("fr-FR")} accent />
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,6 +334,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-h-0 min-w-0">
|
||||
<HeatmapGrid
|
||||
codeFournisseur={codeFournisseur}
|
||||
onSelectionChange={setSelectedCodeins}
|
||||
isAdmin={isAdmin}
|
||||
nomFournisseur={nomFournisseur}
|
||||
|
||||
@@ -97,11 +97,15 @@ function GridFilterBarInner({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
|
||||
// Changement de magasin sans aller-retour serveur : les lignes portent déjà
|
||||
// le détail par magasin. L'URL suit (lien partageable, retour par le menu),
|
||||
// via l'historique du navigateur, que Next répercute sur useSearchParams.
|
||||
const handleStoreSelect = (code: string) => {
|
||||
if (code === activeMagasin) return;
|
||||
setActiveMagasin(code);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("magasin", code);
|
||||
router.replace(`/grid?${params.toString()}`, { scroll: false });
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ import { couleurMarge } from "@/lib/marge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface HeatmapGridProps {
|
||||
/** Fournisseur affiché : la sélection est remise à zéro quand il change. */
|
||||
codeFournisseur: string;
|
||||
onSelectionChange?: (codeins: string[]) => void;
|
||||
isAdmin?: boolean;
|
||||
/** Sert à nommer et documenter les extractions issues de la Grille. */
|
||||
@@ -699,10 +701,14 @@ function VentilationParMagasin({ d }: { d: CellDetailData }) {
|
||||
|
||||
// =========================================================================
|
||||
|
||||
function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
|
||||
function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) {
|
||||
// L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme.
|
||||
// Champs de filtre lus un par un : la recherche ne doit pas recalculer
|
||||
// `filteredData` (donc reconstruire toutes les lignes) à chaque frappe.
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const filters = useGridStore((s) => s.filters);
|
||||
const code3 = useGridStore((s) => s.filters.code3);
|
||||
const codeGamme = useGridStore((s) => s.filters.codeGamme);
|
||||
const search = useGridStore((s) => s.filters.search);
|
||||
const displayDensity = useGridStore((s) => s.displayDensity);
|
||||
const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES);
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
@@ -713,12 +719,16 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
|
||||
// Filtre client-side par code3 (famille) et codeGamme
|
||||
const filteredData = useMemo(() => {
|
||||
const { code3, codeGamme } = filters;
|
||||
// Changement de magasin : les accesseurs de tri (quantité, CA, marge, prix)
|
||||
// dépendent du magasin, et TanStack garde la valeur de chaque ligne en cache
|
||||
// sans jamais retrier sur un changement de colonnes. Un nouveau tableau
|
||||
// donne de nouvelles lignes : tri juste, et cases redessinées aussitôt.
|
||||
void activeMagasin;
|
||||
// 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;
|
||||
if (!codes && !codeGamme) return rows.slice();
|
||||
// Set plutôt que includes() : la liste peut compter des dizaines de
|
||||
// nomenclatures, et le filtre est réévalué pour chaque ligne.
|
||||
const nomenclatures = codes ? new Set(codes) : null;
|
||||
@@ -731,7 +741,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [rows, filters, draftChanges]);
|
||||
}, [rows, code3, codeGamme, draftChanges, activeMagasin]);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
@@ -775,10 +785,12 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
[]
|
||||
);
|
||||
|
||||
// Reset selection when rows change (e.g. supplier change)
|
||||
// Sélection remise à zéro au changement de fournisseur seulement : elle est
|
||||
// indexée par code article, et survit donc à un rechargement des lignes, à un
|
||||
// enregistrement ou au complément des chiffres d'un magasin.
|
||||
useEffect(() => {
|
||||
setRowSelection({});
|
||||
}, [rows]);
|
||||
}, [codeFournisseur]);
|
||||
|
||||
const columns = React.useMemo<ColumnDef<ProductRow>[]>(() => [
|
||||
{
|
||||
@@ -1198,7 +1210,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility, columnSizing },
|
||||
state: { sorting, globalFilter: search, rowSelection, columnVisibility, columnSizing },
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
@@ -1249,7 +1261,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma
|
||||
// Delay update to next tick to ensure we're out of any render cycles
|
||||
setTimeout(() => onSelectionChange(selectedCodeins), 0);
|
||||
}
|
||||
}, [table, rowSelection, onSelectionChange, filteredData, filters.search]);
|
||||
}, [table, rowSelection, onSelectionChange, filteredData, search]);
|
||||
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { CODE_TOUS_MAGASINS } from "@/lib/magasins";
|
||||
import type { StorePatch } from "@/features/grid/lib/store-patch";
|
||||
|
||||
/**
|
||||
* Demande le complément de l'API FF du magasin affiché, une fois les lignes
|
||||
* chargées, et l'applique à son arrivée. Une seule demande par magasin et par
|
||||
* chargement : revenir sur un magasin déjà complété ne coûte rien.
|
||||
*/
|
||||
export function useStorePatch(codeFournisseur: string, enabled: boolean) {
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
const rowsMeta = useGridStore((s) => s.rowsMeta);
|
||||
const applyStorePatch = useGridStore((s) => s.applyStorePatch);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || activeMagasin === CODE_TOUS_MAGASINS || !rowsMeta) return;
|
||||
if (rowsMeta.key !== rowsKeyFor(codeFournisseur) || rowsMeta.patchedStores.includes(activeMagasin)) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const gen = { key: rowsMeta.key, loadedAt: rowsMeta.loadedAt };
|
||||
const params = new URLSearchParams({ fournisseur: codeFournisseur, magasin: activeMagasin });
|
||||
fetch(`/api/grid/rows/store-patch?${params}`, { signal: controller.signal, cache: "no-store" })
|
||||
.then((res) => (res.ok ? (res.json() as Promise<StorePatch>) : null))
|
||||
.then((patch) => {
|
||||
if (patch && !controller.signal.aborted) applyStorePatch(gen, patch);
|
||||
})
|
||||
// Échec ou abandon : les chiffres SQL restent affichés, et la demande
|
||||
// repartira au prochain passage sur ce magasin.
|
||||
.catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [enabled, codeFournisseur, activeMagasin, rowsMeta, applyStorePatch]);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Complément des chiffres d'un magasin par l'API FF (ventes mensuelles).
|
||||
*
|
||||
* Le calcul SQL de la Grille peut manquer les ventes d'un magasin pour certains
|
||||
* articles (quantité à 0 pour ce magasin). L'API FF les connaît : on en tire,
|
||||
* pour ces articles seulement, les séries et totaux du magasin.
|
||||
*
|
||||
* Module sans dépendance d'exécution : utilisé côté serveur (calcul du
|
||||
* complément) comme côté navigateur (application aux lignes affichées).
|
||||
*/
|
||||
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
import type { FfMensuelEntry } from "@/lib/api-ff-client";
|
||||
|
||||
/** Chiffres d'un article pour le magasin complété. */
|
||||
export interface StorePatchEntry {
|
||||
codein: string;
|
||||
/** Ventes par mois (« YYYYMM »). */
|
||||
sales: Record<string, number>;
|
||||
/** Stock de fin de mois, reporté sur les mois sans donnée. */
|
||||
stock: Record<string, number>;
|
||||
receptions: Record<string, number>;
|
||||
quantite: number;
|
||||
ca: number;
|
||||
marge: number;
|
||||
}
|
||||
|
||||
export interface StorePatch {
|
||||
magasin: string;
|
||||
/** Fenêtre des 12 mois du calcul : un complément d'un autre mois est ignoré. */
|
||||
periods: string[];
|
||||
entries: StorePatchEntry[];
|
||||
}
|
||||
|
||||
/** Articles à compléter : connus de FF (noid) et sans aucune vente SQL dans ce magasin. */
|
||||
export function storePatchCandidates(rows: ProductRow[], magasin: string): ProductRow[] {
|
||||
return rows.filter((row) => !!row.noid && (row.quantiteByStore?.[magasin] ?? 0) === 0);
|
||||
}
|
||||
|
||||
/** Chiffres du magasin tirés de l'API, pour les articles où elle montre des ventes. */
|
||||
export function buildStorePatchEntries(
|
||||
retenus: ProductRow[],
|
||||
magasin: string,
|
||||
periods: string[],
|
||||
mensuel: Map<string, FfMensuelEntry[]>,
|
||||
): StorePatchEntry[] {
|
||||
const resultat: StorePatchEntry[] = [];
|
||||
for (const row of retenus) {
|
||||
const entries = (mensuel.get(row.codein) ?? []).filter((entry) => entry.site === magasin);
|
||||
if (entries.length === 0) continue;
|
||||
|
||||
const byPeriod = new Map<string, { qty: number; ca: number; marge: number; stock: number; receptions: number }>();
|
||||
for (const entry of entries) {
|
||||
const period = entry.mois.replace("-", "");
|
||||
if (!periods.includes(period)) continue;
|
||||
// qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
|
||||
// les nie au lieu de Math.abs pour que les retours restent déduits.
|
||||
const qty = -(Number(entry.ventes?.qte_vendue ?? 0) || 0);
|
||||
const ca = -(Number(entry.ventes?.ca_ht ?? 0) || 0);
|
||||
const marge = Number(entry.ventes?.marge ?? 0) || 0;
|
||||
const stock = Number(entry.stock_fin_mois ?? 0) || 0;
|
||||
const receptions = Number(entry.receptions?.qte_recue ?? 0) || 0;
|
||||
byPeriod.set(period, { qty, ca, marge, stock, receptions });
|
||||
}
|
||||
|
||||
const apiQty = [...byPeriod.values()].reduce((sum, value) => sum + value.qty, 0);
|
||||
if (apiQty === 0) continue;
|
||||
|
||||
const e: StorePatchEntry = { codein: row.codein, sales: {}, stock: {}, receptions: {}, quantite: 0, ca: 0, marge: 0 };
|
||||
let lastStock = 0;
|
||||
for (const period of periods) {
|
||||
const value = byPeriod.get(period);
|
||||
e.sales[period] = value?.qty ?? 0;
|
||||
e.receptions[period] = value?.receptions ?? 0;
|
||||
if (value) lastStock = value.stock;
|
||||
e.stock[period] = lastStock;
|
||||
e.quantite += value?.qty ?? 0;
|
||||
e.ca += value?.ca ?? 0;
|
||||
e.marge += value?.marge ?? 0;
|
||||
}
|
||||
resultat.push(e);
|
||||
}
|
||||
return resultat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application historique, côté serveur (`/api/grid/rows?magasin=…`) : corrige le
|
||||
* magasin ET reporte les écarts sur les totaux « tous magasins » de la ligne.
|
||||
* Renvoie le nombre de lignes corrigées.
|
||||
*/
|
||||
export function applyStorePatchInPlace(
|
||||
rows: ProductRow[],
|
||||
magasin: string,
|
||||
periods: string[],
|
||||
entries: StorePatchEntry[],
|
||||
): number {
|
||||
const parCode = new Map(entries.map((e) => [e.codein, e]));
|
||||
let corrigees = 0;
|
||||
for (const row of rows) {
|
||||
const e = parCode.get(row.codein);
|
||||
if (!e) continue;
|
||||
|
||||
row.sales12mByStore ??= {};
|
||||
row.stock12mByStore ??= {};
|
||||
row.receptions12mByStore ??= {};
|
||||
row.caByStore ??= {};
|
||||
row.quantiteByStore ??= {};
|
||||
row.margeByStore ??= {};
|
||||
row.sales12mByStore[magasin] ??= {};
|
||||
row.stock12mByStore[magasin] ??= {};
|
||||
row.receptions12mByStore[magasin] ??= {};
|
||||
|
||||
for (const period of periods) {
|
||||
const currentQty = row.sales12mByStore[magasin][period] ?? 0;
|
||||
const nextQty = e.sales[period] ?? 0;
|
||||
row.sales12mByStore[magasin][period] = nextQty;
|
||||
row.receptions12mByStore[magasin][period] = e.receptions[period] ?? 0;
|
||||
row.stock12mByStore[magasin][period] = e.stock[period] ?? 0;
|
||||
row.sales12m[period] = (row.sales12m[period] ?? 0) + (nextQty - currentQty);
|
||||
}
|
||||
|
||||
const deltaTotalQty = e.quantite - (row.quantiteByStore[magasin] ?? 0);
|
||||
const deltaTotalCa = e.ca - (row.caByStore[magasin] ?? 0);
|
||||
const deltaTotalMarge = e.marge - (row.margeByStore[magasin] ?? 0);
|
||||
|
||||
row.quantiteByStore[magasin] = e.quantite;
|
||||
row.caByStore[magasin] = e.ca;
|
||||
row.margeByStore[magasin] = e.marge;
|
||||
row.totalQuantite += deltaTotalQty;
|
||||
row.totalCa += deltaTotalCa;
|
||||
row.totalMarge += deltaTotalMarge;
|
||||
row.tauxMarge = row.totalCa > 0 ? (row.totalMarge / row.totalCa) * 100 : 0;
|
||||
if (!row.workingStores.includes(magasin)) row.workingStores.push(magasin);
|
||||
corrigees++;
|
||||
}
|
||||
return corrigees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application dans le navigateur : nouvelle ligne, chiffres du magasin seuls.
|
||||
* Les totaux « tous magasins » ne bougent pas, pour que la vue « Nos 2
|
||||
* magasins » reste identique quel que soit le magasin consulté avant.
|
||||
*/
|
||||
export function withStorePatch(row: ProductRow, magasin: string, e: StorePatchEntry): ProductRow {
|
||||
return {
|
||||
...row,
|
||||
sales12mByStore: { ...row.sales12mByStore, [magasin]: e.sales },
|
||||
stock12mByStore: { ...row.stock12mByStore, [magasin]: e.stock },
|
||||
receptions12mByStore: { ...row.receptions12mByStore, [magasin]: e.receptions },
|
||||
quantiteByStore: { ...row.quantiteByStore, [magasin]: e.quantite },
|
||||
caByStore: { ...row.caByStore, [magasin]: e.ca },
|
||||
margeByStore: { ...row.margeByStore, [magasin]: e.marge },
|
||||
workingStores: row.workingStores.includes(magasin) ? row.workingStores : [...row.workingStores, magasin],
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,19 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/grid";
|
||||
import { withStorePatch, type StorePatch } from "@/features/grid/lib/store-patch";
|
||||
import { getMonthsFromRows } from "@/features/grid/lib/months";
|
||||
|
||||
/** Ce que décrit `rowsMeta` : les lignes d'un fournisseur, tous magasins. */
|
||||
export interface RowsMeta {
|
||||
key: string;
|
||||
loadedAt: number;
|
||||
/** Magasins dont le complément de l'API FF est déjà appliqué à ces lignes. */
|
||||
patchedStores: string[];
|
||||
}
|
||||
|
||||
/** Clé des lignes chargées pour un fournisseur (toujours « tous magasins »). */
|
||||
export const rowsKeyFor = (codeFournisseur: string) => `${codeFournisseur}:TOTAL`;
|
||||
|
||||
interface GridState {
|
||||
/** Source data from server */
|
||||
@@ -25,12 +38,17 @@ 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.
|
||||
* Ce que contiennent `rows` et quand elles ont été chargées. Non persisté :
|
||||
* sert à ne pas retélécharger la Grille quand on y revient, et à savoir quels
|
||||
* magasins ont déjà reçu leur complément.
|
||||
*/
|
||||
rowsMeta: { key: string; loadedAt: number } | null;
|
||||
setRowsMeta: (meta: { key: string; loadedAt: number } | null) => void;
|
||||
rowsMeta: RowsMeta | null;
|
||||
setRowsMeta: (meta: RowsMeta | null) => void;
|
||||
/**
|
||||
* Applique le complément d'un magasin, s'il concerne encore les lignes
|
||||
* affichées (`gen` = clé et date de chargement au moment de la demande).
|
||||
*/
|
||||
applyStorePatch: (gen: { key: string; loadedAt: number }, patch: StorePatch) => void;
|
||||
/** Persisted column visibility state */
|
||||
columnVisibility: Record<string, boolean>;
|
||||
/** Persisted column sizing state */
|
||||
@@ -231,6 +249,34 @@ export const useGridStore = create<GridState>()(
|
||||
setActiveGridQuery: (query) => set({ activeGridQuery: sansParamRefresh(query) }),
|
||||
requestRefresh: () => set((state) => ({ refreshRequest: state.refreshRequest + 1 })),
|
||||
setRowsMeta: (meta) => set({ rowsMeta: meta }),
|
||||
applyStorePatch: (gen, patch) => {
|
||||
const { rows, rowsByCodein, rowsMeta } = get();
|
||||
if (!rowsMeta || rowsMeta.key !== gen.key || rowsMeta.loadedAt !== gen.loadedAt) return;
|
||||
const patchedStores = rowsMeta.patchedStores.includes(patch.magasin)
|
||||
? rowsMeta.patchedStores
|
||||
: [...rowsMeta.patchedStores, patch.magasin];
|
||||
// Complément calculé sur une autre fenêtre de 12 mois (changement de
|
||||
// mois entre les deux requêtes) : on le laisse de côté.
|
||||
const mois = getMonthsFromRows(rows);
|
||||
const memeFenetre = !mois || mois.join(",") === patch.periods.join(",");
|
||||
const parCode = new Map(patch.entries.map((e) => [e.codein, e]));
|
||||
if (!memeFenetre || !patch.entries.some((e) => rowsByCodein[e.codein])) {
|
||||
set({ rowsMeta: { ...rowsMeta, patchedStores } });
|
||||
return;
|
||||
}
|
||||
// Nouvelles lignes pour les seuls articles complétés : les autres
|
||||
// gardent leur objet, et donc leur rendu mémorisé.
|
||||
const nouvelles = rows.map((r) => {
|
||||
const e = parCode.get(r.codein);
|
||||
return e ? withStorePatch(r, patch.magasin, e) : r;
|
||||
});
|
||||
set({ rows: nouvelles, rowsByCodein: indexRows(nouvelles), rowsMeta: { ...rowsMeta, patchedStores } });
|
||||
if (summaryTimer) clearTimeout(summaryTimer);
|
||||
summaryTimer = setTimeout(() => {
|
||||
const state = get();
|
||||
set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) });
|
||||
}, 80);
|
||||
},
|
||||
restoreSnapshot: (changes) => {
|
||||
set({ draftChanges: changes, summary: computeSummary(get().rows, changes, get().activeMagasin) });
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user