Lot 6 : Grille plus légère, Commandes allégées et nettoyage

- Grille : réglages écrits dans le localStorage au plus toutes les 500 ms
  (immédiatement à la fermeture), tendance réseau et texte de recherche
  calculés une fois par ligne, courbe de tendance mémorisée, barre du bas
  abonnée au seul nombre de modifications.
- Commandes : appels « franco » 6 par 6 ; dernière réception limitée aux
  fournisseurs du cadencier (résultats identiques sur la base de test).
- Dépendances inutilisées retirées (react-query, @google/genai, ai,
  react-markdown, remark-gfm, xlsx, @radix-ui/react-dropdown-menu) ;
  pnpm-lock.yaml supprimé (npm est utilisé par Docker et le README).
- Code mort et journaux de build retirés.

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-04 06:48:43 +00:00
1 parent 26b9364232
commit af5933b5a7
23 files changed
+324 -13441

No files matched your search

-18
View File
@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
+4 -4
View File
@@ -51,10 +51,10 @@ export async function listCadences(): Promise<CadenceView[]> {
let rows: typeof commandeCadences.$inferSelect[] = [];
let receptions = new Map<string, string>();
try {
[rows, receptions] = await Promise.all([
db.select().from(commandeCadences),
getDerniereReceptionCached(),
]);
rows = await db.select().from(commandeCadences);
// Réceptions des seuls fournisseurs du cadencier : la lecture sur tout
// l'historique de tous les fournisseurs était la plus lourde de la page.
receptions = await getDerniereReceptionCached(rows.map((r) => r.codeFournisseur));
} catch (e) {
console.error("[cadence] listCadences error:", (e as Error).message);
return [];
-25
View File
@@ -1,25 +0,0 @@
"use server";
import { db } from "@/db";
import { sql } from "drizzle-orm";
export async function initSnapshotTable() {
try {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS session_snapshots (
id SERIAL PRIMARY KEY,
code_fournisseur VARCHAR(20) NOT NULL,
nom_fournisseur VARCHAR(255),
magasin VARCHAR(20) NOT NULL,
changes JSONB NOT NULL,
summary_json JSONB,
label TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
`);
return { success: true };
} catch (err) {
console.error("Init DB error:", err);
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}
-23
View File
@@ -2,9 +2,7 @@
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).
@@ -23,24 +21,3 @@ export async function getMagasins() {
await requireSession();
return getSitesFromApi();
}
/**
* Nomenclature filter is disabled — code3 is not available from the API.
* Returns an empty hierarchy so the sidebar filter is hidden gracefully.
*/
export async function getAvailableNomenclature() {
await requireSession();
return {};
}
/**
* Get product data for a specific supplier and store.
*/
export async function getGridData(
codeFournisseur: string,
magasin: string = "TOTAL",
filters?: Partial<GridFilters>
): Promise<ProductRow[]> {
await requireSession();
return getProductRows({ codeFournisseur, magasin, filters });
}
@@ -62,8 +62,7 @@ function FloatingSummaryBarInner({ isAdmin, nomFournisseur }: { isAdmin: boolean
const [isPending, startTransition] = useTransition();
const [enCours, setEnCours] = useState<string | null>(null);
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL", visibleCodeins);
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL");
const supplierCode = filterFournisseur || rows[0]?.codeFournisseur;
const lastQlikUpdate = useMemo(() => {
+39 -11
View File
@@ -11,6 +11,7 @@ import {
ColumnDef,
SortingState,
RowSelectionState,
type FilterFn,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, PackageX, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus, CalendarRange, Tag, Eye, EyeOff } from "lucide-react";
@@ -46,6 +47,7 @@ import {
NB_MAGASINS_RESEAU,
} from "@/features/grid/lib/network-trend";
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
import type { NetworkTrend } from "@/features/grid/lib/network-trend";
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
import { CLASSES_SANS_GAMME, trouverGamme } from "@/lib/gammes";
import { couleurMarge } from "@/lib/marge";
@@ -62,6 +64,39 @@ interface HeatmapGridProps {
const EMPTY_DRAFT_CHANGES: Record<string, GammeCode> = {};
/*
* Valeurs dérivées calculées une fois par ligne. Une ligne reçue ne change plus
* (une modification produit un nouvel objet) : on peut les garder tant qu'elle
* existe, sans rien invalider. Avant, la tendance était recalculée à chaque
* rendu de chaque case, et le texte de recherche à chaque frappe.
*/
const tendances = new WeakMap<ProductRow, NetworkTrend>();
function tendanceDe(row: ProductRow): NetworkTrend {
let t = tendances.get(row);
if (!t) {
t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
tendances.set(row, t);
}
return t;
}
const textesRecherche = new WeakMap<ProductRow, string>();
function texteRecherche(row: ProductRow): string {
let t = textesRecherche.get(row);
if (t === undefined) {
// Saut de ligne entre les champs : une recherche ne peut pas en contenir,
// donc aucune correspondance « à cheval » sur deux champs.
t = [row.libelle1, row.codein, row.reference, row.gtin].map((v) => String(v ?? "")).join("\n").toLowerCase();
textesRecherche.set(row, t);
}
return t;
}
/** Recherche sur libellé, code, référence et code-barres. */
const filtreRecherche: FilterFn<ProductRow> = (row, _columnId, valeur: string) => texteRecherche(row.original).includes(valeur);
// Saisie mise en minuscules une seule fois par frappe, pas une fois par ligne.
filtreRecherche.resolveFilterValue = (valeur) => String(valeur ?? "").toLowerCase();
const QLIK_NETWORK_COLUMN_IDS = new Set<string>([
"caReseau",
"qteReseau",
@@ -528,7 +563,7 @@ interface CellDetailData {
* d'identité — sans quoi la même couleur voudrait dire deux choses.
*/
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
const trend = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
const trend = tendanceDe(row);
const { values, labels, direction, pct } = trend;
const color = TREND_COLOR[direction];
const magasins = computeStoresSeries(row.nbMagReseauByMonth);
@@ -968,7 +1003,7 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
{
id: "tendanceReseau",
accessorFn: (row) => {
const t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
const t = tendanceDe(row);
if (!t.hasData) return Number.NEGATIVE_INFINITY;
// Un produit « nouveau » n'a pas de pourcentage mais c'est la plus
// forte progression possible : il doit remonter en tête du tri, pas
@@ -979,7 +1014,7 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
header: () => <div className="text-center w-full" title="Évolution des ventes du réseau sur 12 mois — cliquer pour le détail">Tendance<br/><span className="text-xs font-normal opacity-75">réseau</span></div>,
size: 78,
cell: ({ row }) => {
const trend = computeNetworkTrend(row.original.qteReseauByMonth, row.original.nbMagReseauByMonth);
const trend = tendanceDe(row.original);
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
// Cliquable → modal des ventes réseau mois par mois (12 derniers mois).
return (
@@ -1227,14 +1262,7 @@ function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFour
// et GTIN de la ligne) : un seul passage par ligne suffit, au lieu d'un par
// colonne filtrable (une quinzaine) à chaque frappe.
getColumnCanGlobalFilter: (column) => column.id === "codein",
globalFilterFn: (row, _columnId, filterValue) => {
const search = String(filterValue).toLowerCase();
const libelle = String(row.original.libelle1 || "").toLowerCase();
const code = String(row.original.codein || "").toLowerCase();
const reference = String(row.original.reference || "").toLowerCase();
const gtin = String(row.original.gtin || "").toLowerCase();
return libelle.includes(search) || code.includes(search) || reference.includes(search) || gtin.includes(search);
},
globalFilterFn: filtreRecherche,
});
const { rows: tableRows } = table.getRowModel();
@@ -7,13 +7,13 @@
* produit (`/produits`). Aucun changement de comportement.
*/
import { useState } from "react";
import { memo, useState } from "react";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { fmtMonthShort } from "@/features/grid/lib/months";
import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-trend";
/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */
export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
function TrendSparklineInner({ trend }: { trend: NetworkTrend }) {
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
const { values, direction, pct, nouveau } = trend;
const color = TREND_COLOR[direction];
@@ -51,6 +51,9 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
);
}
/** Mémorisée : la tendance d'une ligne est calculée une fois et ne change plus. */
export const TrendSparkline = memo(TrendSparklineInner);
/**
* Couleurs des séries : slots 1-3 de la palette catégorielle validée, exposés en
* variables CSS pour que le mode sombre soit un jeu de pas CHOISI et vérifié, et
+26 -33
View File
@@ -1,36 +1,32 @@
"use client";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { saveDraftChanges } from "@/features/grid/api/save-draft-changes";
import { GammeCode } from "@/types/grid";
/** Modifications portant sur les lignes chargées (le fournisseur affiché). */
function modificationsActives(draftChanges: Record<string, GammeCode>, rowsByCodein: Record<string, unknown>) {
return Object.fromEntries(Object.entries(draftChanges).filter(([codein]) => rowsByCodein[codein]));
}
/**
* Builds the payload from Zustand draftChanges and calls the Server Action.
* Filters changes to only include those in the provided codeins list (e.g. current supplier).
* Enregistre les gammes modifiées du fournisseur affiché (server action).
*
* Le composant ne s'abonne qu'au NOMBRE de modifications : l'abonnement aux
* modifications elles-mêmes le redessinait à chaque changement de gamme. Le
* détail est lu au moment d'enregistrer.
*/
export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
const draftChanges = useGridStore((s) => s.draftChanges);
const rowsByCodein = useGridStore((s) => s.rowsByCodein);
const resetDrafts = useGridStore((s) => s.resetDrafts);
const clearDrafts = useGridStore((s) => s.clearDrafts);
const applyDraftsToRows = useGridStore((s) => s.applyDraftsToRows);
// Only consider changes that are in the filter list (if provided)
const filterCodeinSet = useMemo(
() => filterCodeins ? new Set(filterCodeins) : null,
[filterCodeins]
);
const activeDrafts = useMemo(
() => filterCodeinSet
? Object.fromEntries(Object.entries(draftChanges).filter(([codein]) => filterCodeinSet.has(codein)))
: draftChanges,
[draftChanges, filterCodeinSet]
);
const count = Object.keys(activeDrafts).length;
export function useSaveDrafts(magasin: string) {
const count = useGridStore((s) => {
let n = 0;
for (const codein in s.draftChanges) if (s.rowsByCodein[codein]) n++;
return n;
});
const save = useCallback(async () => {
const { draftChanges, rowsByCodein, applyDraftsToRows, clearDrafts } = useGridStore.getState();
const activeDrafts = modificationsActives(draftChanges, rowsByCodein);
// Index par code article : `rows.find` dans la boucle coûtait
// (modifications × lignes) comparaisons sur un gros fournisseur.
const changes = Object.entries(activeDrafts).map(([codein, codeGamme]) => {
@@ -44,25 +40,22 @@ export function useSaveDrafts(magasin: string, filterCodeins?: string[]) {
if (changes.length === 0) return { success: true, saved: 0 };
// Derive supplier info from the first matching row
const firstRow = changes.length > 0 ? rowsByCodein[changes[0].codein] : undefined;
// Fournisseur déduit de la première ligne modifiée
const firstRow = rowsByCodein[changes[0].codein];
const codeFournisseur = firstRow?.codeFournisseur ?? "";
const nomFournisseur = firstRow?.nomFournisseur ?? "";
const result = await saveDraftChanges({ codeFournisseur, nomFournisseur, magasin, changes });
if (result.success) {
// Apply saved values onto row.codeGamme BEFORE clearing drafts
// so that isModified (codeGamme !== codeGammeInit) stays true
// Report des valeurs enregistrées sur row.codeGamme AVANT de vider les
// modifications, pour que l'indicateur « modifié » (codeGamme !==
// codeGammeInit) reste juste.
applyDraftsToRows(activeDrafts);
if (filterCodeins) {
clearDrafts(Object.keys(activeDrafts));
} else {
resetDrafts();
}
clearDrafts(Object.keys(activeDrafts));
}
return result;
}, [activeDrafts, rowsByCodein, magasin, resetDrafts, clearDrafts, applyDraftsToRows, filterCodeins]);
}, [magasin]);
return { save, hasDrafts: count > 0, count };
}
@@ -2,6 +2,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { createThrottledJSONStorage } from "@/lib/throttled-json-storage";
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";
@@ -317,6 +318,9 @@ export const useGridStore = create<GridState>()(
}),
{
name: "collectflow-grid-storage",
// Écriture différée (500 ms) : `persist` réécrivait le localStorage à
// chaque changement d'état, y compris pendant un redimensionnement.
storage: createThrottledJSONStorage<GridPersisted>(500),
/**
* v1 — `filters.code3` est passé d'une chaîne unique à `string[] | null`
* (filtre multi-nomenclatures). Sans migration, la valeur déjà présente
@@ -1,27 +0,0 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
/**
* Score Settings Store — v4 (simplifié)
*
* Le score hybride v4 utilise des paliers absolus (Volume 50pts + CA 30pts + Marge 20pts + Bonus ±10pts).
* Il n'y a plus de pondérations configurables par l'utilisateur.
* Ce store est conservé pour la compatibilité mais n'a plus de paramètres ajustables.
*/
interface ScoreSettingsState {
_version: number;
}
export const useScoreSettingsStore = create<ScoreSettingsState>()(
persist(
() => ({
_version: 4,
}),
{
name: "collectflow-score-settings",
}
)
);
-115
View File
@@ -291,52 +291,6 @@ export function buildLast12MonthsRange(): { dateDebut: string; dateFin: string }
};
}
/** "2025-03-15" → "202503" */
export function dateToYYYYMM(isoDate: string): string {
return isoDate.slice(0, 7).replace("-", "");
}
// ---------------------------------------------------------------------------
// Fournisseurs
// ---------------------------------------------------------------------------
/** Extrait le code fournisseur depuis n'importe quelle forme de réponse API */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractFouCode(f: any): string {
return f.codefou ?? f.CodeFou ?? f.code_fournisseur ?? f.codeFournisseur ?? f.code ?? String(f.id ?? "");
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractFouNom(f: any): string {
return f.nomfou ?? f.NomFou ?? f.nom_fournisseur ?? f.nomFournisseur ?? f.nom ?? f.libelle ?? f.name ?? "Inconnu";
}
export async function getFournisseursFromApi(
search?: string
): Promise<{ code: string; nom: string }[]> {
const FF_API_BASE = await getFfApiBase();
try {
const url = search
? `${FF_API_BASE}/api/fournisseurs?search=${encodeURIComponent(search)}&limit=500`
: `${FF_API_BASE}/api/fournisseurs?limit=500`;
console.log(`[api-ff] GET ${url}`);
const res = await fetch(url, ffFetchInit());
if (!res.ok) throw new Error(`HTTP ${res.status} on ${url}`);
const data = await res.json();
// Log premier item pour diagnostic
const rawList = extractList(data);
console.log(`[api-ff] fournisseurs: ${rawList.length} items, sample keys:`, rawList[0] ? Object.keys(rawList[0] as object) : "empty");
return rawList
.map((f: unknown) => ({ code: extractFouCode(f), nom: extractFouNom(f) }))
.filter((f: { code: string; nom: string }) => f.code);
} catch (err) {
console.error("[api-ff] getFournisseursFromApi error:", err);
return [];
}
}
// ---------------------------------------------------------------------------
// Articles
// ---------------------------------------------------------------------------
@@ -370,63 +324,6 @@ export async function getArticlesByFournisseur(
// Mouvements (ventes + stock12m)
// ---------------------------------------------------------------------------
export async function getMouvementsByFournisseur(
codefou: string,
dateDebut: string,
dateFin: string
): Promise<FfMouvement[]> {
const FF_API_BASE = await getFfApiBase();
const raw = await fetchAllPages<unknown>(
(page) =>
`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
extractList,
1000
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return raw.map((r: any): FfMouvement => ({
codein: r.codein ?? r.Codein ?? r.code_article ?? "",
genremvt: Number(r.genremvt ?? r.GenreMvt ?? r.genre_mvt ?? r.type ?? 0),
datemvt: r.datmvt ?? r.datemvt ?? r.DateMvt ?? r.date_mvt ?? r.date ?? "",
qte: Number(r.qtemvt ?? r.qte ?? r.Qte ?? r.quantite ?? 0),
montant: Number(r.mntmvtht ?? r.montant ?? r.Montant ?? r.montant_mvt ?? r.ca ?? 0),
marge: Number(r.margemvt ?? r.marge ?? r.Marge ?? r.marge_mvt ?? 0),
qtestock: Number(r.qtestock ?? r.QteStock ?? r.qte_stock ?? r.stock ?? 0),
site: r.site ?? r.Site ?? r.magasin ?? r.code_magasin ?? r.codesite ?? "",
codefou_reel: r.codefou_reel ?? undefined,
nom_fournisseur_reel: r.nom_fournisseur_reel ?? undefined,
})).filter(m => m.codein && m.datemvt);
}
/**
* Retourne les mouvements pour une plage de dates (sans filtre fournisseur).
* Utilisé pour extraire codefou_reel / nom_fournisseur_reel par codein.
*/
export async function getMouvementsForDate(
dateDebut: string,
dateFin: string
): Promise<FfMouvement[]> {
const FF_API_BASE = await getFfApiBase();
const raw = await fetchAllPages<unknown>(
(page) =>
`${FF_API_BASE}/api/mouvements/articles?dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
extractList,
1000
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return raw.map((r: any): FfMouvement => ({
codein: r.codein ?? r.Codein ?? r.code_article ?? "",
genremvt: Number(r.genremvt ?? r.GenreMvt ?? r.genre_mvt ?? r.type ?? 0),
datemvt: r.datmvt ?? r.datemvt ?? r.DateMvt ?? r.date_mvt ?? r.date ?? "",
qte: Number(r.qtemvt ?? r.qte ?? r.Qte ?? r.quantite ?? 0),
montant: Number(r.mntmvtht ?? r.montant ?? r.Montant ?? r.montant_mvt ?? r.ca ?? 0),
marge: Number(r.margemvt ?? r.marge ?? r.Marge ?? r.marge_mvt ?? 0),
qtestock: Number(r.qtestock ?? r.QteStock ?? r.qte_stock ?? r.stock ?? 0),
site: r.site ?? r.Site ?? r.magasin ?? r.code_magasin ?? r.codesite ?? "",
codefou_reel: r.codefou_reel ?? undefined,
nom_fournisseur_reel: r.nom_fournisseur_reel ?? undefined,
})).filter(m => m.codein && m.datemvt);
}
// ---------------------------------------------------------------------------
// Mensuel — stock fin de mois + ventes + réceptions par article et par site
// GET /api/articles/:no_id/mensuel?dateDebut=&dateFin=
@@ -608,18 +505,6 @@ export async function getCommandesByFournisseur(
// Statut de synchronisation
// ---------------------------------------------------------------------------
export async function getSyncStatus(): Promise<FfSyncStatus | null> {
try {
const FF_API_BASE = await getFfApiBase();
const res = await fetch(`${FF_API_BASE}/api/sync/status`, ffFetchInit());
if (!res.ok) return null;
return normalizeSyncStatus(await res.json());
} catch (err) {
console.error("[api-ff] getSyncStatus error:", err);
return null;
}
}
// ---------------------------------------------------------------------------
// Sites (magasins)
// ---------------------------------------------------------------------------
+8 -5
View File
@@ -100,12 +100,15 @@ export function getFournisseursCached(): Promise<{ code: string; nom: string }[]
}
/**
* Dernière réception par fournisseur et magasin (cadencier). Agrège tout
* l'historique de `mvtart` : de loin la lecture la plus lourde de la page
* Commandes. Vide = erreur avalée, non gardé.
* Dernière réception par fournisseur et magasin, pour les fournisseurs du
* cadencier seulement : de loin la lecture la plus lourde de la page Commandes.
* Clé = liste triée, donc recalculée quand une cadence est ajoutée ou retirée.
* Vide = erreur avalée (ou aucune réception), non gardé.
*/
export function getDerniereReceptionCached(): Promise<Map<string, string>> {
return cachedFF("derniere-reception", () => pgGetDerniereReceptionParFournisseur(), {
export function getDerniereReceptionCached(codes: string[]): Promise<Map<string, string>> {
const liste = [...new Set(codes.map((c) => c.trim()))].sort();
if (liste.length === 0) return Promise.resolve(new Map());
return cachedFF(`derniere-reception:${liste.join(",")}`, () => pgGetDerniereReceptionParFournisseur(liste), {
cacheIf: (map) => map.size > 0,
});
}
-6
View File
@@ -58,9 +58,3 @@ export function trouverGamme(code: string | null | undefined): Gamme | undefined
if (!code) return undefined;
return GAMMES.find((g) => g.code === code.trim());
}
/** « A — Cœur », ou « Sans gamme ». */
export function libelleGamme(code: string | null | undefined): string {
const g = trouverGamme(code);
return g ? `${g.code} — ${g.nom}` : "Sans gamme";
}
+52 -73
View File
@@ -25,6 +25,28 @@ let _nomenclatureParentCol: string | null | undefined = undefined; // undefined
// la transaction BEGIN / SET LOCAL / COMMIT qui entourait chaque requête coûtait
// trois allers-retours de plus à chacune.
// ---------------------------------------------------------------------------
/**
* `fn` sur chaque élément, au plus `simultanes` à la fois, résultats dans l'ordre
* (même forme que Promise.allSettled). Évite de lancer des centaines d'appels à
* l'API FF d'un coup, qui la saturent et finissent en délais dépassés.
*/
async function parLots<T, R>(items: T[], simultanes: number, fn: (item: T) => Promise<R>): Promise<PromiseSettledResult<R>[]> {
const resultats: PromiseSettledResult<R>[] = new Array(items.length);
let suivant = 0;
const ouvrier = async () => {
while (suivant < items.length) {
const i = suivant++;
try {
resultats[i] = { status: "fulfilled", value: await fn(items[i]) };
} catch (reason) {
resultats[i] = { status: "rejected", reason };
}
}
};
await Promise.all(Array.from({ length: Math.min(simultanes, items.length) }, ouvrier));
return resultats;
}
async function pgNoParallel(query: SQL): Promise<{ rows: unknown[] }> {
const r = await db.execute(query);
return { rows: r.rows };
@@ -156,55 +178,6 @@ export async function pgGetArticlesByFournisseur(codefou: string): Promise<PgArt
return (result.rows as unknown as PgArticle[]).filter(r => r.codein);
}
/** Article local (FF Nancy) résolu depuis un code centrale — page Recherche réseau. */
export interface PgArticleLocal {
codein: string;
codeCentrale: string;
libelle1?: string;
codefou?: string;
nomfou?: string;
pa?: number;
pv_central?: number;
}
/**
* Résout des codes centraux vers NOS articles locaux.
*
* Sert à distinguer, dans les résultats de recherche Qlik, les produits que nous
* référençons (« Chez moi ») de ceux qui n'existent que dans le réseau. Un code
* absent de la Map = produit inconnu de FF Nancy.
*/
export async function pgGetArticlesByCodeCentrale(codes: string[]): Promise<Map<string, PgArticleLocal>> {
const unique = [...new Set(codes.map(c => String(c ?? "").trim()).filter(Boolean))];
if (unique.length === 0) return new Map();
const result = await pgNoParallel(sql`
SELECT DISTINCT ON (a.artcentrale)
TRIM(a.codein::text) AS codein,
a.artcentrale AS "codeCentrale",
a.libelle1,
af.code AS codefou,
fi.nom AS nomfou,
pa.pa,
ai.prix_vente_mini AS pv_central
FROM articles a
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id
LEFT JOIN fouident fi ON fi.code = af.code
LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id
LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id
WHERE TRIM(a.artcentrale::text) IN (${sql.join(unique.map(c => sql`${c}`), sql`, `)})
AND a.codein IS NOT NULL
ORDER BY a.artcentrale, af.no_id
`);
const map = new Map<string, PgArticleLocal>();
for (const r of result.rows as unknown as PgArticleLocal[]) {
if (r.codeCentrale) map.set(String(r.codeCentrale).trim(), r);
}
console.log(`[pg-ff] pgGetArticlesByCodeCentrale: ${map.size}/${unique.length} codes connus localement`);
return map;
}
// ---------------------------------------------------------------------------
// 2. Données mensuelles (ventes + stock + réceptions)
// ---------------------------------------------------------------------------
@@ -1426,29 +1399,28 @@ export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
return Number(resumeItem?.franco_ht ?? resumeItem?.franco ?? 0);
};
const details = await Promise.allSettled(
uniqueCodeFous.map(async codefou => {
// 1. Essai sans filtre site
try {
const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, ffFetchInit());
if (res2.ok) {
const detail = await res2.json();
const f = extractFranco(detail);
if (f > 0) return { codefou, franco: f };
}
} catch { /* ignore */ }
// 2. Fallback site=000
try {
const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, ffFetchInit());
if (res3.ok) {
const detail = await res3.json();
const f = extractFranco(detail);
if (f > 0) return { codefou, franco: f };
}
} catch { /* ignore */ }
return { codefou, franco: 0 };
})
);
// 6 fournisseurs à la fois : chacun peut coûter deux appels successifs.
const details = await parLots(uniqueCodeFous, 6, async (codefou) => {
// 1. Essai sans filtre site
try {
const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, ffFetchInit());
if (res2.ok) {
const detail = await res2.json();
const f = extractFranco(detail);
if (f > 0) return { codefou, franco: f };
}
} catch { /* ignore */ }
// 2. Fallback site=000
try {
const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, ffFetchInit());
if (res3.ok) {
const detail = await res3.json();
const f = extractFranco(detail);
if (f > 0) return { codefou, franco: f };
}
} catch { /* ignore */ }
return { codefou, franco: 0 };
});
// Map codefou → franco enrichi
const francoMap = new Map<string, number>();
@@ -1490,8 +1462,14 @@ export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
* Clé de la Map : `${codefou}|${site}`. Map vide en cas d'erreur
* (l'UI affichera alors « échéance inconnue »).
*/
export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string, string>> {
export async function pgGetDerniereReceptionParFournisseur(codes?: string[]): Promise<Map<string, string>> {
const map = new Map<string, string>();
// Liste fournie (fournisseurs du cadencier) : on n'agrège que leurs articles,
// au lieu de tout l'historique de réceptions de tous les fournisseurs.
if (codes && codes.length === 0) return map;
const filtreFournisseurs = codes
? sql`AND TRIM(af.code) IN (${sql.join(codes.map((c) => sql`${c.trim()}`), sql`, `)})`
: sql``;
try {
const result = await pgNoParallel(sql`
SELECT
@@ -1504,6 +1482,7 @@ export async function pgGetDerniereReceptionParFournisseur(): Promise<Map<string
WHERE m.genremvt IN (1, 2)
AND m.site IN ('292', '579')
AND m.datmvt IS NOT NULL
${filtreFournisseurs}
GROUP BY af.code, m.site
`);
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { createJSONStorage, type PersistStorage, type StorageValue } from "zustand/middleware";
/**
* Stockage `persist` de zustand qui n'écrit dans le localStorage qu'au plus une
* fois par `delaiMs`.
*
* `persist` réécrit (et resérialise) l'état à chaque `set`, quel que soit le
* champ modifié : deux fois par changement de gamme, et à chaque mouvement de
* souris pendant qu'on élargit une colonne. Ici, seule la dernière valeur est
* écrite, et elle l'est aussitôt quand la page est quittée ou masquée.
*/
export function createThrottledJSONStorage<S>(delaiMs = 500): PersistStorage<S> | undefined {
// Rendu serveur : même repli que zustand par défaut (rien n'est persisté).
if (typeof window === "undefined") return undefined;
const inner = createJSONStorage<S>(() => window.localStorage);
if (!inner) return undefined;
let enAttente: { name: string; value: StorageValue<S> } | null = null;
let minuteur: number | null = null;
const ecrire = () => {
if (minuteur !== null) {
clearTimeout(minuteur);
minuteur = null;
}
if (!enAttente) return;
const { name, value } = enAttente;
enAttente = null;
try {
inner.setItem(name, value);
} catch {
/* quota dépassé ou stockage bloqué : ignoré, comme avant */
}
};
window.addEventListener("pagehide", ecrire);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") ecrire();
});
return {
getItem: (name) => (enAttente?.name === name ? enAttente.value : inner.getItem(name)),
setItem: (name, value) => {
enAttente = { name, value };
if (minuteur === null) minuteur = window.setTimeout(ecrire, delaiMs);
},
removeItem: (name) => {
if (enAttente?.name === name) enAttente = null;
inner.removeItem(name);
},
};
}