mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-15 19:28:52 +02:00
perf: lot 1 — chargements plus rapides et corrections, sans changement visuel
Grille (navigateur) - « Rafraîchir » ne passe plus par l'URL : le paramètre _refresh restait mémorisé et chaque retour par le menu forçait un recalcul serveur complet (migration du store pour nettoyer les valeurs déjà enregistrées). - Lignes reçues regroupées toutes les 300 ms au lieu de reconstruire tout le tableau à chaque paquet de 150 (chargement quadratique). - Sélection indexée par code article (getRowId) : après un filtre, l'action groupée visait d'autres produits. Seules les lignes affichées comptent. - Recherche : un passage par ligne au lieu d'un par colonne ; formateurs de nombres partagés (lib/format.ts) ; colonnes mensuelles mémorisées ; plus de transition-all sur les lignes positionnées par transform. - exceljs, jspdf et xlsx chargés au clic seulement. Grille (serveur) - Requêtes Qlik et snapshot lancées en parallèle de la phase SQL. - Cache mémoire borné (LRU) ; calcul en cours réutilisé même en forcé ; flux interrompu quand le client part. - Après un enregistrement, les gammes sont reportées dans le cache et dans grid_rows au lieu d'invalider (recalcul de ~40 s évité). Données et API - lib/ff-cache.ts : cache mémoire 30 min des lectures FF (stock, hit-parade, CA mensuel, fournisseurs, dernière réception, publicités), vidé en fin de synchro nocturne ; erreurs jamais gardées. - Pool PostgreSQL unique (globalThis), réglage sans workers parallèles posé une fois par connexion au lieu d'une transaction autour de chaque requête. - Délai maximal sur tous les appels à l'API FF. - Index session_snapshots ; commandes : plus de double rechargement ; synchro : saisies regroupées ; paramètres : configuration lue une fois ; journal de capture en ajout seul, rien de formaté sans capture ouverte. Corrections - Historique : l'échec de chargement s'affiche (au lieu de « Aucun snapshot »). - Sessions et validations enregistrent le magasin affiché, plus « TOTAL ». - Publicités : libellé du statut « passées ». Plan complet : docs/plan-optimisation-webui.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
35 files changed
+1010
-240
No files matched your search
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle,
|
||||
CheckCircle, Database, Network, Search,
|
||||
@@ -114,6 +114,10 @@ export function SynchronisationClient() {
|
||||
return () => clearInterval(id);
|
||||
}, [state?.enCours, rafraichirEtat]);
|
||||
|
||||
// Saisie numérique pas encore envoyée (cf. majSettingsDifferee).
|
||||
const patchEnAttente = useRef<Partial<SyncSettings>>({});
|
||||
const minuteurPatch = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const majSettings = async (patch: Partial<SyncSettings>) => {
|
||||
setErreur(null);
|
||||
try {
|
||||
@@ -122,10 +126,26 @@ export function SynchronisationClient() {
|
||||
});
|
||||
const d = await res.json();
|
||||
if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`);
|
||||
setSettings(d.settings);
|
||||
// Une saisie faite pendant l'envoi ne doit pas être écrasée par la réponse.
|
||||
setSettings({ ...d.settings, ...patchEnAttente.current });
|
||||
} catch (e) { setErreur(e instanceof Error ? e.message : String(e)); }
|
||||
};
|
||||
|
||||
// Champs numériques : la valeur s'affiche tout de suite et l'envoi au serveur
|
||||
// est regroupé 600 ms après la dernière frappe. Avant, chaque chiffre tapé
|
||||
// partait en PATCH, et une réponse tardive pouvait écraser la saisie en cours.
|
||||
const majSettingsDifferee = (patch: Partial<SyncSettings>) => {
|
||||
setSettings((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
patchEnAttente.current = { ...patchEnAttente.current, ...patch };
|
||||
if (minuteurPatch.current) clearTimeout(minuteurPatch.current);
|
||||
minuteurPatch.current = setTimeout(() => {
|
||||
const aEnvoyer = patchEnAttente.current;
|
||||
patchEnAttente.current = {};
|
||||
minuteurPatch.current = null;
|
||||
void majSettings(aEnvoyer);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const action = async (act: "start" | "stop" | "seed", forcer = false) => {
|
||||
setErreur(null);
|
||||
try {
|
||||
@@ -207,7 +227,7 @@ export function SynchronisationClient() {
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>De</label>
|
||||
<input type="number" min={0} max={23} value={settings.heureDebut}
|
||||
onChange={(e) => majSettings({ heureDebut: Number(e.target.value) })}
|
||||
onChange={(e) => majSettingsDifferee({ heureDebut: Number(e.target.value) })}
|
||||
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
@@ -215,7 +235,7 @@ export function SynchronisationClient() {
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>À</label>
|
||||
<input type="number" min={0} max={23} value={settings.heureFin}
|
||||
onChange={(e) => majSettings({ heureFin: Number(e.target.value) })}
|
||||
onChange={(e) => majSettingsDifferee({ heureFin: Number(e.target.value) })}
|
||||
className="w-16 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
@@ -225,14 +245,14 @@ export function SynchronisationClient() {
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik / nuit</label>
|
||||
<input type="number" min={0} max={200} value={settings.qlikParNuit}
|
||||
onChange={(e) => majSettings({ qlikParNuit: Number(e.target.value) })}
|
||||
onChange={(e) => majSettingsDifferee({ qlikParNuit: Number(e.target.value) })}
|
||||
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik si + vieux que</label>
|
||||
<input type="number" min={0} max={365} value={settings.qlikMinJours}
|
||||
onChange={(e) => majSettings({ qlikMinJours: Number(e.target.value) })}
|
||||
onChange={(e) => majSettingsDifferee({ qlikMinJours: Number(e.target.value) })}
|
||||
className="w-20 rounded-lg px-2 py-1.5 text-[13px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { pgGetCaByFournisseur, pgGetCaByNomenclature } from "@/lib/pg-ff-client";
|
||||
import { AnalyticsClient } from "./client";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
// Calcul du mois N-1 (même mois année précédente)
|
||||
function getMoisN1(mois: string): string {
|
||||
@@ -84,7 +85,7 @@ export default async function AnalyticsPage(props: {
|
||||
[];
|
||||
|
||||
if (mode === "fournisseur") {
|
||||
const result = await pgGetCaByFournisseur(mois, moisN1);
|
||||
const result = await cachedFF(`ca-fournisseur:${mois}:${moisN1}`, () => pgGetCaByFournisseur(mois, moisN1));
|
||||
rows = result.map((r) => ({
|
||||
code: r.code,
|
||||
label: r.nom,
|
||||
@@ -93,7 +94,7 @@ export default async function AnalyticsPage(props: {
|
||||
ca_ttc: r.ca_ttc,
|
||||
}));
|
||||
} else {
|
||||
const result = await pgGetCaByNomenclature(mois, moisN1);
|
||||
const result = await cachedFF(`ca-nomenclature:${mois}:${moisN1}`, () => pgGetCaByNomenclature(mois, moisN1));
|
||||
rows = result.map((r) => ({
|
||||
code: r.code,
|
||||
label: `${r.code} — ${r.libelle}`,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarClock,
|
||||
@@ -162,17 +161,17 @@ function SiteSection({
|
||||
rows: CadenceView[];
|
||||
fournisseurs: { code: string; nom: string }[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const sorted = useMemo(() => trier(rows), [rows]);
|
||||
const existingCodes = useMemo(() => new Set(rows.map((r) => r.codefou)), [rows]);
|
||||
const nbACommander = rows.filter((r) => r.actif && r.statut === "a_commander").length;
|
||||
|
||||
// Pas de router.refresh() : les actions appellent déjà revalidatePath, dont
|
||||
// la réponse rafraîchit la page. Les deux ensemble rechargeaient tout deux fois.
|
||||
function run(fn: () => Promise<unknown>) {
|
||||
startTransition(async () => {
|
||||
await fn();
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pgGetCommandesAuto, type PgCommandeAutoRow } from "@/lib/pg-ff-client";
|
||||
import { listCadences, getFournisseursPourCadence } from "@/features/commandes-auto/actions";
|
||||
import { CommandesAutoTabs } from "./tabs";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
export type { PgCommandeAutoRow };
|
||||
|
||||
@@ -9,7 +10,13 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function CommandesAutoPage() {
|
||||
const [rows, cadences, fournisseurs] = await Promise.all([
|
||||
pgGetCommandesAuto(),
|
||||
// Propositions de l'API FF (un appel, plus un par fournisseur sans franco) :
|
||||
// gardées 2 minutes, pour que chaque modification du cadencier — qui
|
||||
// re-rend la page — n'attende pas de nouveau toute l'API.
|
||||
cachedFF("commandes-auto", () => pgGetCommandesAuto(), {
|
||||
ttlMs: 2 * 60 * 1000,
|
||||
cacheIf: (rows) => rows.length > 0,
|
||||
}),
|
||||
listCadences(),
|
||||
getFournisseursPourCadence(),
|
||||
]);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import * as XLSX from "xlsx";
|
||||
import type { HitParadePivotRow } from "./page";
|
||||
|
||||
type SortKey = "caTotal" | "ca292" | "ca579" | "qteTotal" | "qte292" | "qte579" | "stockTotal" | "stock292" | "stock579";
|
||||
@@ -136,7 +135,9 @@ export function HitParadeClient({ dateDebut, dateFin, pivotted }: Props) {
|
||||
{ qte292: 0, ca292: 0, marge292: 0, qte579: 0, ca579: 0, marge579: 0, qteTotal: 0, caTotal: 0, margeTotal: 0, stock292: 0, stock579: 0, stockTotal: 0 }
|
||||
), [sorted]);
|
||||
|
||||
function exportToExcel() {
|
||||
async function exportToExcel() {
|
||||
// Chargée au clic seulement : la bibliothèque pèse plusieurs centaines de Ko.
|
||||
const XLSX = await import("xlsx");
|
||||
const headers = [
|
||||
"Code", "Désignation", "Fournisseur", "Nomenclature",
|
||||
"Qté Nancy", "CA TTC Nancy", "% Marge Nancy", "Stock Nancy",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { pgGetHitParade, HitParadeRow } from "@/lib/pg-ff-client";
|
||||
import { HitParadeClient } from "./client";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
export interface HitParadePivotRow {
|
||||
codein: string;
|
||||
@@ -80,7 +81,7 @@ export default async function HitParadePage(props: {
|
||||
const dateDebut = (searchParams.debut as string) || defaults.debut;
|
||||
const dateFin = (searchParams.fin as string) || defaults.fin;
|
||||
|
||||
const rows = await pgGetHitParade(dateDebut, dateFin);
|
||||
const rows = await cachedFF(`hit-parade:${dateDebut}:${dateFin}`, () => pgGetHitParade(dateDebut, dateFin));
|
||||
const pivotted = pivotHitParade(rows);
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,6 +26,8 @@ function SortIcon({ col, sortKey, sortDir }: { col: SortKey; sortKey: SortKey; s
|
||||
const STATUT_LABELS: Record<string, { label: string; cls: string }> = {
|
||||
en_cours: { label: "En cours", cls: "bg-emerald-100 text-emerald-700" },
|
||||
passee: { label: "Passée", cls: "bg-gray-100 text-gray-600" },
|
||||
// Le filtre envoie `passees` à l'API : la même valeur peut revenir sur les lignes.
|
||||
passees: { label: "Passée", cls: "bg-gray-100 text-gray-600" },
|
||||
a_venir: { label: "À venir", cls: "bg-blue-100 text-blue-700" },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PublicitesClient } from "./client";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
export interface Publicite {
|
||||
tcr_code: string;
|
||||
@@ -40,17 +41,31 @@ export interface PubliciteHistorique {
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
const PAGE_SIZE = 50;
|
||||
/** Délai maximal d'un appel à l'API FF : sans lui, une API muette figeait la page. */
|
||||
const FF_API_TIMEOUT_MS = 15_000;
|
||||
/**
|
||||
* Réponses de l'API gardées 10 minutes (les erreurs ne le sont jamais) : la liste
|
||||
* et l'historique étaient re-téléchargés à chaque clic sur un filtre ou une page.
|
||||
*/
|
||||
const PUB_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
async function fetchPublicites(statut: string, page: number): Promise<{
|
||||
data: Publicite[];
|
||||
total: number;
|
||||
pages: number;
|
||||
error?: string;
|
||||
}> {
|
||||
type PublicitesResult = { data: Publicite[]; total: number; pages: number; error?: string };
|
||||
|
||||
function fetchPublicites(statut: string, page: number): Promise<PublicitesResult> {
|
||||
return cachedFF(`publicites:${statut}:${page}`, () => fetchPublicitesApi(statut, page), {
|
||||
ttlMs: PUB_CACHE_TTL_MS,
|
||||
cacheIf: (r) => !r.error,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPublicitesApi(statut: string, page: number): Promise<PublicitesResult> {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) });
|
||||
if (statut && statut !== "toutes") params.set("statut", statut);
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/publicites?${params}`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/publicites?${params}`, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(FF_API_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return { data: [], total: 0, pages: 0, error: `Erreur API: ${res.status} ${res.statusText}` };
|
||||
const json = await res.json();
|
||||
return {
|
||||
@@ -63,9 +78,21 @@ async function fetchPublicites(statut: string, page: number): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHistorique(): Promise<{ data: PubliciteHistorique[]; error?: string }> {
|
||||
type HistoriqueResult = { data: PubliciteHistorique[]; error?: string };
|
||||
|
||||
function fetchHistorique(): Promise<HistoriqueResult> {
|
||||
return cachedFF("publicites-historique", fetchHistoriqueApi, {
|
||||
ttlMs: PUB_CACHE_TTL_MS,
|
||||
cacheIf: (r) => !r.error,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchHistoriqueApi(): Promise<HistoriqueResult> {
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/publicites/historique`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/publicites/historique`, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(FF_API_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return { data: [], error: `Erreur historique: ${res.status}` };
|
||||
const json = await res.json();
|
||||
return { data: (json.publicites ?? []) as PubliciteHistorique[] };
|
||||
|
||||
@@ -13,6 +13,22 @@ import { ApiConnectionInfo } from "@/features/admin/components/api-connection-in
|
||||
import { GridWarmup } from "@/features/admin/components/grid-warmup";
|
||||
import { ServerLogs } from "@/features/settings/components/server-logs";
|
||||
|
||||
/**
|
||||
* Configuration enregistrée, lue UNE fois pour toute la page : trois sections
|
||||
* (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions
|
||||
* serveur s'exécutent les unes après les autres.
|
||||
*/
|
||||
let savedConfigPromise: ReturnType<typeof getSavedDatabaseConfig> | null = null;
|
||||
function loadSavedConfig(force = false): ReturnType<typeof getSavedDatabaseConfig> {
|
||||
if (force || !savedConfigPromise) {
|
||||
savedConfigPromise = getSavedDatabaseConfig().catch((e) => {
|
||||
savedConfigPromise = null;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return savedConfigPromise;
|
||||
}
|
||||
|
||||
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="apple-card">
|
||||
@@ -51,7 +67,7 @@ function FfApiStatusSection() {
|
||||
|
||||
// Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut).
|
||||
useEffect(() => {
|
||||
getSavedDatabaseConfig()
|
||||
loadSavedConfig()
|
||||
.then((cfg) => {
|
||||
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
|
||||
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
|
||||
@@ -177,7 +193,7 @@ function QlikSettingsSection() {
|
||||
const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSavedDatabaseConfig().then((c) => {
|
||||
loadSavedConfig().then((c) => {
|
||||
if (!c) return;
|
||||
setHost(c.qlikHost ?? "");
|
||||
setUser(c.qlikUser ?? "");
|
||||
@@ -265,8 +281,8 @@ export default function SettingsPage() {
|
||||
getDatabaseUrl
|
||||
} = useDbSettingsStore();
|
||||
|
||||
const reloadFromServer = useCallback(async () => {
|
||||
const config = await getSavedDatabaseConfig();
|
||||
const reloadFromServer = useCallback(async (force = false) => {
|
||||
const config = await loadSavedConfig(force);
|
||||
if (config) {
|
||||
if (config.url) {
|
||||
// Parser l'URL pour remettre dans le store
|
||||
@@ -290,7 +306,9 @@ export default function SettingsPage() {
|
||||
// au montage, ce que la règle déconseille en général mais qui est ici le but.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsMounted(true);
|
||||
reloadFromServer();
|
||||
// Lecture fraîche à chaque ouverture de la page ; les sections, montées
|
||||
// juste après (cf. `isMounted`), réutilisent cette même requête.
|
||||
reloadFromServer(true);
|
||||
}, [reloadFromServer]);
|
||||
|
||||
const testDb = async () => {
|
||||
@@ -410,7 +428,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={reloadFromServer}
|
||||
onClick={() => reloadFromServer(true)}
|
||||
className="apple-btn-secondary h-8 px-3 text-[11px] opacity-80 hover:opacity-100"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { pgGetStockNegatif, PgStockNegatifRow, pgGetStockSansVente, PgStockSansVenteRow, pgGetSansVente6Mois, PgSansVente6MoisRow } from "@/lib/pg-ff-client";
|
||||
import { GestionStockClient } from "./client";
|
||||
import { cachedFF } from "@/lib/ff-cache";
|
||||
|
||||
export type { PgStockNegatifRow, PgStockSansVenteRow, PgSansVente6MoisRow };
|
||||
|
||||
@@ -15,10 +16,12 @@ export default async function GestionStockPage(props: {
|
||||
const magasin = (searchParams.magasin as string) || "";
|
||||
const tab = (searchParams.tab as string) || "negatif";
|
||||
|
||||
// Données FF recopiées chaque nuit : mises en cache (cf. lib/ff-cache.ts).
|
||||
const site = magasin || undefined;
|
||||
const [rowsNegatif, rowsSansVente, rowsSansVente6Mois] = await Promise.all([
|
||||
pgGetStockNegatif(magasin || undefined),
|
||||
pgGetStockSansVente(magasin || undefined),
|
||||
pgGetSansVente6Mois(magasin || undefined),
|
||||
cachedFF(`stock-negatif:${magasin}`, () => pgGetStockNegatif(site)),
|
||||
cachedFF(`stock-sans-vente:${magasin}`, () => pgGetStockSansVente(site)),
|
||||
cachedFF(`stock-sans-vente-6-mois:${magasin}`, () => pgGetSansVente6Mois(site)),
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { GridFilters } from "@/types/grid";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function writeNdjson(controller: ReadableStreamDefaultController<Uint8Array>, value: unknown) {
|
||||
controller.enqueue(new TextEncoder().encode(`${JSON.stringify(value)}\n`));
|
||||
controller.enqueue(encoder.encode(`${JSON.stringify(value)}\n`));
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -26,14 +28,25 @@ export async function GET(request: NextRequest) {
|
||||
};
|
||||
const forceRefresh = searchParams.get("refresh") === "1";
|
||||
|
||||
// Client parti (changement de fournisseur, page quittée) : on arrête de
|
||||
// sérialiser et d'envoyer. Le calcul lui-même continue — il est partagé et
|
||||
// mis en cache, la prochaine ouverture en profitera.
|
||||
let cancelled = false;
|
||||
request.signal.addEventListener("abort", () => { cancelled = true; });
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
async start(controller) {
|
||||
try {
|
||||
writeNdjson(controller, { type: "start", loaded: 0, total: null });
|
||||
const rows = await getProductRows({ codeFournisseur, magasin, filters, forceRefresh });
|
||||
if (cancelled) return;
|
||||
const total = rows.length;
|
||||
|
||||
for (let start = 0; start < total; start += chunkSize) {
|
||||
if (cancelled) return;
|
||||
const chunk = rows.slice(start, start + chunkSize);
|
||||
writeNdjson(controller, {
|
||||
type: "chunk",
|
||||
@@ -46,12 +59,17 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
writeNdjson(controller, { type: "done", loaded: total, total });
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
writeNdjson(controller, {
|
||||
type: "error",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
controller.close();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* flux déjà fermé côté client */
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+36
-11
@@ -4,12 +4,19 @@ import * as schema from "./schema";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
let pool: Pool | null = null;
|
||||
let currentDb: NodePgDatabase<typeof schema> | null = null;
|
||||
/**
|
||||
* Pool unique par processus, rangé dans `globalThis` : l'instrumentation (synchro
|
||||
* nocturne) et les routes peuvent être compilées dans des lots distincts, chacun
|
||||
* avec sa propre copie de ce module — et donc son propre pool de 20 connexions.
|
||||
* Même chose à chaque rechargement à chaud en développement.
|
||||
*/
|
||||
type DbState = { pool: Pool | null; db: NodePgDatabase<typeof schema> | null };
|
||||
const globalForDb = globalThis as typeof globalThis & { __collectflowDb?: DbState };
|
||||
const state: DbState = (globalForDb.__collectflowDb ??= { pool: null, db: null });
|
||||
|
||||
export function getPool(): Pool {
|
||||
getDb(); // ensure pool is initialized
|
||||
return pool!;
|
||||
return state.pool!;
|
||||
}
|
||||
|
||||
function maskUrl(url: string | undefined) {
|
||||
@@ -21,7 +28,7 @@ function maskUrl(url: string | undefined) {
|
||||
* Initializes or returns the current database instance.
|
||||
*/
|
||||
export function getDb() {
|
||||
if (currentDb) return currentDb;
|
||||
if (state.db) return state.db;
|
||||
|
||||
let connectionString = "";
|
||||
|
||||
@@ -54,19 +61,37 @@ export function getDb() {
|
||||
const connectionHostname = connectionString.match(/@([^:/]+)/)?.[1] || "unknown";
|
||||
console.log(`[DB] Target Hostname: ${connectionHostname}`);
|
||||
|
||||
pool = new Pool({
|
||||
// Délai maximal par requête : opt-in (les calculs nocturnes des plus gros
|
||||
// fournisseurs peuvent être longs), pour qu'une requête emballée ne monopolise
|
||||
// pas indéfiniment une des 20 connexions.
|
||||
const statementTimeout = Number(process.env.PG_STATEMENT_TIMEOUT_MS) || undefined;
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 10000,
|
||||
application_name: "collectflow",
|
||||
...(statementTimeout ? { statement_timeout: statementTimeout } : {}),
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('[DB] Unexpected error on idle client', err);
|
||||
});
|
||||
|
||||
currentDb = drizzle(pool, { schema });
|
||||
return currentDb;
|
||||
// Pas de workers parallèles PostgreSQL : ils consomment /dev/shm, trop petit
|
||||
// dans le conteneur, et font échouer les grosses agrégations. Réglé une fois
|
||||
// par connexion (la requête est mise en file avant toute autre sur ce client)
|
||||
// au lieu d'une transaction BEGIN / SET LOCAL / COMMIT autour de chaque requête.
|
||||
pool.on('connect', (client) => {
|
||||
client.query("SET max_parallel_workers_per_gather = 0").catch((err) => {
|
||||
console.error('[DB] SET max_parallel_workers_per_gather failed:', err?.message);
|
||||
});
|
||||
});
|
||||
|
||||
state.pool = pool;
|
||||
state.db = drizzle(pool, { schema });
|
||||
return state.db;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,11 +100,11 @@ export function getDb() {
|
||||
*/
|
||||
export function refreshDb() {
|
||||
console.log("[DB] Refreshing database connection...");
|
||||
if (pool) {
|
||||
pool.end().catch(err => console.error("[DB] Error closing pool:", err));
|
||||
pool = null;
|
||||
if (state.pool) {
|
||||
state.pool.end().catch(err => console.error("[DB] Error closing pool:", err));
|
||||
state.pool = null;
|
||||
}
|
||||
currentDb = null;
|
||||
state.db = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
-1
@@ -75,7 +75,11 @@ export const sessionSnapshots = pgTable("session_snapshots", {
|
||||
/** snapshot vs export */
|
||||
type: varchar("type", { length: 20 }).default("snapshot"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
});
|
||||
}, (table) => [
|
||||
// Créés par scripts/db-init.js (la migration réellement jouée au démarrage).
|
||||
index("idx_snapshots_fournisseur_date").on(table.codeFournisseur, table.createdAt.desc()),
|
||||
index("idx_snapshots_user_type_date").on(table.userId, table.type, table.createdAt.desc()),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Cadencier / alertes de commande par fournisseur ET par magasin.
|
||||
|
||||
@@ -33,6 +33,7 @@ import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright";
|
||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
|
||||
import { readSyncSettings, type SyncSettings } from "@/features/admin/api/sync-settings";
|
||||
import { invalidateFFCache } from "@/lib/ff-cache";
|
||||
|
||||
export type { SyncSettings };
|
||||
|
||||
@@ -338,6 +339,9 @@ export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre
|
||||
etat.derniereErreur = e instanceof Error ? e.message : String(e);
|
||||
console.error("[sync-nuit] round interrompu :", etat.derniereErreur);
|
||||
} finally {
|
||||
// Les pages (stock, hit-parade, CA…) relisent la base fraîche au lieu
|
||||
// d'attendre la fin de vie de leur cache.
|
||||
invalidateFFCache();
|
||||
etat.enCours = false;
|
||||
etat.fournisseurCourant = null;
|
||||
etat.etape = null;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { commandeCadences } from "@/db/schema";
|
||||
import { pgGetFournisseurs, pgGetDerniereReceptionParFournisseur } from "@/lib/pg-ff-client";
|
||||
import { getDerniereReceptionCached, getFournisseursCached } from "@/lib/ff-cache";
|
||||
|
||||
export type CadenceStatut = "a_commander" | "bientot" | "ok" | "inconnu";
|
||||
|
||||
@@ -37,7 +37,7 @@ function calcStatut(joursRestants: number | null): CadenceStatut {
|
||||
|
||||
/** Liste des fournisseurs (référentiel) pour le sélecteur de cadence. */
|
||||
export async function getFournisseursPourCadence(): Promise<{ code: string; nom: string }[]> {
|
||||
return pgGetFournisseurs();
|
||||
return getFournisseursCached();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +50,7 @@ export async function listCadences(): Promise<CadenceView[]> {
|
||||
try {
|
||||
[rows, receptions] = await Promise.all([
|
||||
db.select().from(commandeCadences),
|
||||
pgGetDerniereReceptionParFournisseur(),
|
||||
getDerniereReceptionCached(),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error("[cadence] listCadences error:", (e as Error).message);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getSitesFromApi } from "@/lib/api-ff-client";
|
||||
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
import { getFournisseursCached } from "@/lib/ff-cache";
|
||||
import { getProductRows } from "./api/get-product-rows";
|
||||
import type { ProductRow, GridFilters } from "@/types/grid";
|
||||
|
||||
@@ -9,7 +9,9 @@ import type { ProductRow, GridFilters } from "@/types/grid";
|
||||
* Get the list of all suppliers from PostgreSQL (fouadr1).
|
||||
*/
|
||||
export async function getFournisseurs() {
|
||||
return pgGetFournisseurs();
|
||||
// Relu à chaque navigation dans la Grille (changement de fournisseur, de
|
||||
// magasin…) : le référentiel ne change qu'avec la recopie nocturne.
|
||||
return getFournisseursCached();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache";
|
||||
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 { upsertGridRows } from "@/lib/grid-store";
|
||||
@@ -28,8 +28,32 @@ interface GetProductRowsInput {
|
||||
}
|
||||
|
||||
const GRID_ROWS_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
/**
|
||||
* Bornes du cache mémoire. La synchro nocturne et le préchauffage passent par
|
||||
* `getProductRows()` pour CHAQUE fournisseur : sans plafond, le processus finissait
|
||||
* par garder toutes les grilles en mémoire (plusieurs centaines de Mo), ce qui
|
||||
* ralentissait toutes les requêtes (ramasse-miettes). Ordre d'insertion de la Map
|
||||
* = ordre d'usage : on évince les entrées les moins récemment servies.
|
||||
*/
|
||||
const GRID_ROWS_CACHE_MAX_ENTRIES = Number(process.env.GRID_ROWS_CACHE_MAX_ENTRIES ?? 30);
|
||||
const GRID_ROWS_CACHE_MAX_ROWS = Number(process.env.GRID_ROWS_CACHE_MAX_ROWS ?? 150_000);
|
||||
const gridRowsCache = new Map<string, { rows: ProductRow[]; createdAt: number }>();
|
||||
const gridRowsPending = new Map<string, Promise<ProductRow[]>>();
|
||||
/** Nombre d'enregistrements de gammes par fournisseur (cf. patchGridRowsCache). */
|
||||
const gammeSaveVersion = new Map<string, number>();
|
||||
|
||||
function cacheGridRows(key: string, rows: ProductRow[]): void {
|
||||
gridRowsCache.delete(key);
|
||||
gridRowsCache.set(key, { rows, createdAt: Date.now() });
|
||||
let totalRows = 0;
|
||||
for (const entry of gridRowsCache.values()) totalRows += entry.rows.length;
|
||||
for (const [oldKey, entry] of gridRowsCache) {
|
||||
if (gridRowsCache.size <= 1) break;
|
||||
if (gridRowsCache.size <= GRID_ROWS_CACHE_MAX_ENTRIES && totalRows <= GRID_ROWS_CACHE_MAX_ROWS) break;
|
||||
gridRowsCache.delete(oldKey);
|
||||
totalRows -= entry.rows.length;
|
||||
}
|
||||
}
|
||||
|
||||
function gridRowsCacheKey(input: GetProductRowsInput): string {
|
||||
return `${input.codeFournisseur}:${input.magasin ?? "TOTAL"}`;
|
||||
@@ -49,6 +73,31 @@ export function invalidateGridRowsCache(codeFournisseur?: string) {
|
||||
gridRowsPending.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reporte des gammes enregistrées sur les lignes déjà en cache, au lieu de jeter
|
||||
* le cache : l'invalidation forçait un recalcul complet (jusqu'à ~40 s sur un gros
|
||||
* fournisseur) à la réouverture suivante. Le résultat est celui qu'aurait donné
|
||||
* ce recalcul : la colonne Gamme reprend la valeur du dernier snapshot (Phase 9),
|
||||
* la colonne INIT reste l'état serveur.
|
||||
*/
|
||||
export function patchGridRowsCache(
|
||||
codeFournisseur: string,
|
||||
changes: ReadonlyArray<{ codein: string; codeGamme: string }>,
|
||||
): void {
|
||||
if (changes.length === 0) return;
|
||||
// Un calcul en cours pour ce fournisseur a lu les gammes avant cet
|
||||
// enregistrement : le compteur lui signale de les relire avant d'être caché.
|
||||
gammeSaveVersion.set(codeFournisseur, (gammeSaveVersion.get(codeFournisseur) ?? 0) + 1);
|
||||
const byCodein = new Map(changes.map((c) => [c.codein, c.codeGamme as GammeCode]));
|
||||
for (const [key, entry] of gridRowsCache) {
|
||||
if (!key.startsWith(`${codeFournisseur}:`)) continue;
|
||||
for (const row of entry.rows) {
|
||||
const gamme = byCodein.get(row.codein);
|
||||
if (gamme !== undefined) row.codeGamme = gamme;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchit UNIQUEMENT la colonne INIT (codeGammeInit = gamme serveur courante)
|
||||
* sur des lignes servies depuis le cache. La colonne Gamme (codeGamme) n'est pas
|
||||
@@ -90,19 +139,30 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
|
||||
const cacheKey = gridRowsCacheKey(input);
|
||||
const cached = gridRowsCache.get(cacheKey);
|
||||
if (!input.forceRefresh && cached && Date.now() - cached.createdAt < GRID_ROWS_CACHE_TTL_MS) {
|
||||
// Remonte l'entrée en tête de l'ordre d'usage (éviction LRU).
|
||||
gridRowsCache.delete(cacheKey);
|
||||
gridRowsCache.set(cacheKey, cached);
|
||||
// La colonne INIT doit refléter l'état serveur à CHAQUE chargement,
|
||||
// y compris sur un hit de cache (les données lourdes restent, elles, cachées).
|
||||
await refreshGammeInit(cached.rows, input.codeFournisseur);
|
||||
return cached.rows;
|
||||
}
|
||||
|
||||
// Un calcul déjà en cours est réutilisé même en rechargement forcé : il part
|
||||
// de données fraîches, et en lancer un second en parallèle doublait la charge
|
||||
// (double clic, onglet rouvert…) sans rien apporter.
|
||||
const pending = gridRowsPending.get(cacheKey);
|
||||
if (!input.forceRefresh && pending) {
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const promise = buildProductRows(input).then((rows) => {
|
||||
gridRowsCache.set(cacheKey, { rows, createdAt: Date.now() });
|
||||
const versionAuDepart = gammeSaveVersion.get(input.codeFournisseur) ?? 0;
|
||||
const promise = buildProductRows(input).then(async (rows) => {
|
||||
if ((gammeSaveVersion.get(input.codeFournisseur) ?? 0) !== versionAuDepart) {
|
||||
// Gammes enregistrées pendant le calcul : on reprend le dernier snapshot.
|
||||
applySnapshotChanges(rows, await loadLatestSnapshotChanges(input.codeFournisseur));
|
||||
}
|
||||
cacheGridRows(cacheKey, rows);
|
||||
gridRowsPending.delete(cacheKey);
|
||||
// Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on
|
||||
// arrête simplement de jeter le résultat. Volontairement NON bloquant —
|
||||
@@ -127,6 +187,15 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
|
||||
// ─── Phase 1 : 7 requêtes SQL en parallèle ────────────────────────────
|
||||
// Remplace des centaines/milliers d'appels HTTP per-article.
|
||||
const articlesPromise = pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; });
|
||||
// Phases 8 et 9 lancées dès maintenant : elles n'attendent que la liste des
|
||||
// articles (codes centraux) ou le seul code fournisseur. Exécutées après la
|
||||
// Phase 1, elles ajoutaient deux allers-retours en série à chaque calcul.
|
||||
const networkPromise = articlesPromise.then((arts) =>
|
||||
fetchNetworkMetrics(arts.map((a) => (a.codeCentrale ? String(a.codeCentrale).trim() : ""))),
|
||||
);
|
||||
const snapshotPromise = loadLatestSnapshotChanges(codeFournisseur);
|
||||
|
||||
const [
|
||||
articles,
|
||||
mensuelRows,
|
||||
@@ -136,7 +205,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
commandesMap,
|
||||
prixVenteMap,
|
||||
] = await Promise.all([
|
||||
pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }),
|
||||
articlesPromise,
|
||||
pgGetMensuelByFournisseur(codeFournisseur, dateDebut, dateFin).catch(e => { console.error("[getProductRows] pgGetMensuelByFournisseur ERROR:", e); return []; }),
|
||||
pgGetGammesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetGammesByFournisseur ERROR:", e); return new Map<string, string>(); }),
|
||||
pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }),
|
||||
@@ -407,34 +476,13 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
}
|
||||
|
||||
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
|
||||
await enrichWithNetworkMetrics(productMap);
|
||||
enrichWithNetworkMetrics(productMap, await networkPromise);
|
||||
|
||||
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
|
||||
// La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle.
|
||||
// La colonne INIT (codeGammeInit) reste, elle, l'état LIVE du serveur
|
||||
// rechargé en Phase 6 — jamais écrasée ici.
|
||||
try {
|
||||
const snaps = await db
|
||||
.select()
|
||||
.from(sessionSnapshots)
|
||||
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
|
||||
.orderBy(desc(sessionSnapshots.createdAt))
|
||||
.limit(1);
|
||||
|
||||
if (snaps.length > 0) {
|
||||
const changes = snaps[0].changes as Record<string, { before: string | null; after: string }>;
|
||||
for (const [codein, change] of Object.entries(changes)) {
|
||||
const product = productMap.get(codein);
|
||||
if (product && change.after) {
|
||||
// codeGammeInit reste figé (état serveur) — seul codeGamme est overridé
|
||||
product.codeGamme = change.after as GammeCode;
|
||||
}
|
||||
}
|
||||
console.log(`[getProductRows] Gammes restaurées depuis snapshot`);
|
||||
}
|
||||
} catch (snapErr) {
|
||||
console.error("[getProductRows] Snapshot restore error:", snapErr);
|
||||
}
|
||||
applySnapshotChanges(productMap.values(), await snapshotPromise);
|
||||
|
||||
// ─── Phase 10 : Filtrer gamme Y sans ventes ──────────────────────────
|
||||
const allRows = Array.from(productMap.values());
|
||||
@@ -601,41 +649,74 @@ function nbMagParMois(
|
||||
return Object.keys(parMois).length > 0 ? parMois : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrichit les produits avec les metriques reseau Qlik (cache qlik_network_metrics),
|
||||
* jointes par code centrale. Degradation propre si la sync Qlik n'a jamais tourne
|
||||
* ou si le code centrale n'est pas encore disponible.
|
||||
*/
|
||||
async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Promise<void> {
|
||||
try {
|
||||
const byCodeCentrale = new Map<string, ProductRow[]>();
|
||||
for (const product of productMap.values()) {
|
||||
const cc = product.codeCentrale;
|
||||
if (!cc) continue;
|
||||
if (!byCodeCentrale.has(cc)) byCodeCentrale.set(cc, []);
|
||||
byCodeCentrale.get(cc)!.push(product);
|
||||
}
|
||||
if (byCodeCentrale.size === 0) return;
|
||||
type SnapshotChanges = Record<string, { before: string | null; after: string }>;
|
||||
|
||||
const metrics = await getNetworkMetricsByCodeCentrale([...byCodeCentrale.keys()]);
|
||||
const sampleKey = [...byCodeCentrale.keys()].find((cc) => (metrics.get(cc)?.caReseau ?? 0) > 0) ?? [...byCodeCentrale.keys()].find((cc) => metrics.has(cc));
|
||||
console.log(`[getProductRows] réseau: ${byCodeCentrale.size} codes centraux → ${metrics.size} matchés en cache. Sample:`, sampleKey ? JSON.stringify(metrics.get(sampleKey)) : "aucun match");
|
||||
for (const [cc, products] of byCodeCentrale.entries()) {
|
||||
const m = metrics.get(cc);
|
||||
if (!m) continue;
|
||||
for (const product of products) {
|
||||
product.caReseau = m.caReseau;
|
||||
product.qteReseau = m.qteReseau;
|
||||
product.nbMagasinsReseau = m.nbMagasinsReseau;
|
||||
product.caParMagasinReseau = m.caParMagasinReseau;
|
||||
product.margePctReseau = m.margePctReseau;
|
||||
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
|
||||
product.qteReseauByMonth = m.qteByMonth ?? null;
|
||||
product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth);
|
||||
product.networkFetchedAt = m.fetchedAt ?? undefined;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Phase 9 : la colonne Gamme (codeGamme) reprend la valeur du snapshot ;
|
||||
* codeGammeInit reste figé (état serveur).
|
||||
*/
|
||||
function applySnapshotChanges(rows: Iterable<ProductRow>, changes: SnapshotChanges | null): void {
|
||||
if (!changes) return;
|
||||
for (const row of rows) {
|
||||
const change = changes[row.codein];
|
||||
if (change?.after) row.codeGamme = change.after as GammeCode;
|
||||
}
|
||||
}
|
||||
|
||||
/** Dernières gammes enregistrées pour ce fournisseur (Phase 9), ou `null`. */
|
||||
async function loadLatestSnapshotChanges(codeFournisseur: string): Promise<SnapshotChanges | null> {
|
||||
try {
|
||||
const snaps = await db
|
||||
.select({ changes: sessionSnapshots.changes })
|
||||
.from(sessionSnapshots)
|
||||
.where(eq(sessionSnapshots.codeFournisseur, codeFournisseur))
|
||||
.orderBy(desc(sessionSnapshots.createdAt))
|
||||
.limit(1);
|
||||
return snaps.length > 0 ? (snaps[0].changes as SnapshotChanges) : null;
|
||||
} catch (snapErr) {
|
||||
console.error("[getProductRows] Snapshot restore error:", snapErr);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit les metriques reseau Qlik (cache qlik_network_metrics) des codes centraux
|
||||
* donnes. `null` en cas d'erreur : la grille s'affiche alors sans donnees reseau.
|
||||
*/
|
||||
async function fetchNetworkMetrics(
|
||||
codesCentrale: string[],
|
||||
): Promise<Map<string, NetworkMetricCached> | null> {
|
||||
try {
|
||||
const codes = [...new Set(codesCentrale.filter(Boolean))];
|
||||
if (codes.length === 0) return null;
|
||||
return await getNetworkMetricsByCodeCentrale(codes);
|
||||
} catch (error) {
|
||||
console.error("[getProductRows] enrichWithNetworkMetrics error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrichit les produits avec les metriques reseau Qlik, jointes par code centrale.
|
||||
* Degradation propre si la sync Qlik n'a jamais tourne ou si le code centrale
|
||||
* n'est pas encore disponible.
|
||||
*/
|
||||
function enrichWithNetworkMetrics(
|
||||
productMap: Map<string, ProductRow>,
|
||||
metrics: Map<string, NetworkMetricCached> | null,
|
||||
): void {
|
||||
if (!metrics || metrics.size === 0) return;
|
||||
for (const product of productMap.values()) {
|
||||
const m = product.codeCentrale ? metrics.get(product.codeCentrale) : undefined;
|
||||
if (!m) continue;
|
||||
product.caReseau = m.caReseau;
|
||||
product.qteReseau = m.qteReseau;
|
||||
product.nbMagasinsReseau = m.nbMagasinsReseau;
|
||||
product.caParMagasinReseau = m.caParMagasinReseau;
|
||||
product.margePctReseau = m.margePctReseau;
|
||||
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
|
||||
product.qteReseauByMonth = m.qteByMonth ?? null;
|
||||
product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth);
|
||||
product.networkFetchedAt = m.fetchedAt ?? undefined;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { invalidateGridRowsCache } from "./get-product-rows";
|
||||
import { patchGridRowsCache } from "./get-product-rows";
|
||||
import { updateGridRowsGamme } from "@/lib/grid-store";
|
||||
|
||||
const SaveDraftsSchema = z.object({
|
||||
codeFournisseur: z.string(),
|
||||
@@ -70,7 +71,13 @@ export async function saveDraftChanges(
|
||||
type: "snapshot",
|
||||
});
|
||||
|
||||
invalidateGridRowsCache(codeFournisseur);
|
||||
// Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées :
|
||||
// l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la
|
||||
// réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer.
|
||||
patchGridRowsCache(codeFournisseur, changes);
|
||||
void updateGridRowsGamme(codeFournisseur, changes).catch((e) =>
|
||||
console.error("[saveDraftChanges] mise à jour grid_rows KO:", (e as Error).message?.slice(0, 200)),
|
||||
);
|
||||
|
||||
return { success: true, saved: changes.length };
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Download, FileSpreadsheet, FileText, ChevronDown, Table2 } from "lucide-react";
|
||||
import * as ExcelJS from "exceljs";
|
||||
import jsPDF from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
|
||||
// exceljs, jspdf et jspdf-autotable (~1 Mo) ne sont chargés qu'au clic : importés
|
||||
// en tête de fichier, ils alourdissaient la page Grille pour tous les utilisateurs.
|
||||
// Les lignes sont lues dans le store au moment de l'export, sans abonnement : le
|
||||
// menu n'a pas à se redessiner à chaque modification de la Grille.
|
||||
|
||||
export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { rows, draftChanges } = useGridStore();
|
||||
|
||||
const handleExportAllProducts = async () => {
|
||||
setIsOpen(false);
|
||||
const { rows, draftChanges } = useGridStore.getState();
|
||||
if (rows.length === 0) {
|
||||
alert("Aucune donnée à exporter.");
|
||||
return;
|
||||
@@ -59,7 +61,9 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
|
||||
|
||||
const handleExportExcel = async () => {
|
||||
setIsOpen(false);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const { rows, draftChanges } = useGridStore.getState();
|
||||
const { Workbook } = await import("exceljs");
|
||||
const workbook = new Workbook();
|
||||
const worksheet = workbook.addWorksheet("Gamme A");
|
||||
|
||||
worksheet.columns = [
|
||||
@@ -100,8 +104,13 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handlePrintPDF = () => {
|
||||
const handlePrintPDF = async () => {
|
||||
setIsOpen(false);
|
||||
const { rows, draftChanges } = useGridStore.getState();
|
||||
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||
import("jspdf"),
|
||||
import("jspdf-autotable"),
|
||||
]);
|
||||
const doc = new jsPDF({ orientation: "landscape", unit: "mm", format: "a4" });
|
||||
|
||||
doc.setFontSize(16);
|
||||
|
||||
@@ -36,7 +36,10 @@ export function FloatingSummaryBar() {
|
||||
const summary = useGridStore((s) => s.summary);
|
||||
const resetDrafts = useGridStore((s) => s.resetDrafts);
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const filters = useGridStore((s) => s.filters);
|
||||
const filterFournisseur = useGridStore((s) => s.filters.codeFournisseur);
|
||||
// Magasin affiché (bascule de la Grille). `filters.magasin` n'est jamais
|
||||
// renseigné : les sessions et validations étaient toutes notées « TOTAL ».
|
||||
const activeMagasin = useGridStore((s) => s.activeMagasin);
|
||||
const draftChanges = useGridStore((s) => s.draftChanges);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
|
||||
@@ -54,9 +57,9 @@ export function FloatingSummaryBar() {
|
||||
});
|
||||
|
||||
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
|
||||
const { save, hasDrafts, count } = useSaveDrafts(filters.magasin || "TOTAL", visibleCodeins);
|
||||
const { save, hasDrafts, count } = useSaveDrafts(activeMagasin || "TOTAL", visibleCodeins);
|
||||
|
||||
const supplierCode = filters.codeFournisseur || rows[0]?.codeFournisseur;
|
||||
const supplierCode = filterFournisseur || rows[0]?.codeFournisseur;
|
||||
const lastQlikUpdate = useMemo(() => {
|
||||
let max: string | null = null;
|
||||
for (const r of rows) {
|
||||
@@ -99,9 +102,9 @@ export function FloatingSummaryBar() {
|
||||
);
|
||||
|
||||
const res = await saveSnapshot({
|
||||
codeFournisseur: filters.codeFournisseur || rows[0].codeFournisseur,
|
||||
codeFournisseur: filterFournisseur || rows[0].codeFournisseur,
|
||||
nomFournisseur: rows[0].nomFournisseur,
|
||||
magasin: filters.magasin || "TOTAL",
|
||||
magasin: activeMagasin || "TOTAL",
|
||||
label: labelOverride || `${type === 'export' ? 'Export' : 'Session'} ${rows[0].nomFournisseur} — ${new Date().toLocaleTimeString()}`,
|
||||
changes,
|
||||
type,
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ProductRow } from "@/types/grid";
|
||||
import { CheckCircle, AlertCircle, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import type { GridFilters } from "@/types/grid";
|
||||
|
||||
interface GridClientProps {
|
||||
@@ -51,7 +51,7 @@ function MetricPill({ label, value, accent = false }: { label: string; value: st
|
||||
);
|
||||
}
|
||||
|
||||
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin, filters }: GridClientProps) {
|
||||
export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
|
||||
const setRows = useGridStore((s) => s.setRows);
|
||||
@@ -61,8 +61,6 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const setCode3Filter = useGridStore((s) => s.setCode3Filter);
|
||||
const setActiveMagasin = useGridStore((s) => s.setActiveMagasin);
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [selectedCodeins, setSelectedCodeins] = useState<string[]>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
@@ -101,7 +99,13 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
}
|
||||
}, [codeFournisseur, setFilter, setCode3Filter, isMounted]);
|
||||
|
||||
const refreshToken = searchParams.get("_refresh");
|
||||
// Rechargement forcé (bouton « Rafraîchir », fin de synchro Qlik) : demandé via
|
||||
// le store, pas via l'URL. On retient la dernière demande déjà servie pour ne
|
||||
// pas re-forcer le recalcul serveur à chaque remontage ou changement de filtre.
|
||||
const refreshRequest = useGridStore((s) => s.refreshRequest);
|
||||
const requestRefresh = useGridStore((s) => s.requestRefresh);
|
||||
const servedRefreshRef = useRef(refreshRequest);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
|
||||
@@ -109,14 +113,14 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const params = new URLSearchParams();
|
||||
params.set("fournisseur", codeFournisseur);
|
||||
params.set("magasin", magasin || "TOTAL");
|
||||
// `code3` n'est volontairement pas transmis : le serveur ignore ce filtre
|
||||
// (getProductRows ne s'en sert pas) et la Grille l'applique en local. Le
|
||||
// faire voyager relançait un chargement complet à chaque case cochée.
|
||||
if (filters.code1) params.set("code1", filters.code1);
|
||||
if (filters.code2) params.set("code2", filters.code2);
|
||||
if (refreshToken) params.set("refresh", "1");
|
||||
// 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");
|
||||
|
||||
let accumulatedRows: ProductRow[] = [];
|
||||
const accumulatedRows: ProductRow[] = [];
|
||||
let lastFlush = 0;
|
||||
setRows([]);
|
||||
setRowsLoaded(0);
|
||||
setTotalRows(null);
|
||||
@@ -124,6 +128,20 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
setIsLoadingRows(true);
|
||||
setShowStartOverlay(true);
|
||||
|
||||
// Chaque mise à jour du store reconstruit tout le tableau (index, tri,
|
||||
// filtres, compteurs) : la faire à chaque paquet de 150 lignes rendait le
|
||||
// chargement quadratique. On regroupe donc les paquets et on ne pousse les
|
||||
// lignes que toutes les FLUSH_MS (et une dernière fois à la fin).
|
||||
const FLUSH_MS = 300;
|
||||
// Les lignes déjà poussées dans le store ne doivent plus bouger : on lui
|
||||
// passe une copie, et on continue d'accumuler dans le tableau privé.
|
||||
const flush = (loaded: number, total: number | null, final = false) => {
|
||||
setRows(final ? accumulatedRows : accumulatedRows.slice());
|
||||
setRowsLoaded(loaded);
|
||||
setTotalRows(total);
|
||||
lastFlush = Date.now();
|
||||
};
|
||||
|
||||
async function loadRows() {
|
||||
try {
|
||||
const response = await fetch(`/api/grid/rows?${params.toString()}`, {
|
||||
@@ -137,6 +155,9 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let loaded = 0;
|
||||
let total: number | null = null;
|
||||
let pending = false;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -150,18 +171,26 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
if (!line.trim()) continue;
|
||||
const message = JSON.parse(line) as GridRowsStreamMessage;
|
||||
if (message.type === "chunk") {
|
||||
accumulatedRows = accumulatedRows.concat(message.rows);
|
||||
setRows(accumulatedRows);
|
||||
setRowsLoaded(message.loaded);
|
||||
setTotalRows(message.total);
|
||||
for (const row of message.rows) accumulatedRows.push(row);
|
||||
loaded = message.loaded;
|
||||
total = message.total;
|
||||
pending = true;
|
||||
} else if (message.type === "done") {
|
||||
setRowsLoaded(message.loaded);
|
||||
setTotalRows(message.total);
|
||||
loaded = message.loaded;
|
||||
total = message.total;
|
||||
} else if (message.type === "error") {
|
||||
throw new Error(message.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (pending && Date.now() - lastFlush >= FLUSH_MS) {
|
||||
flush(loaded, total);
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
flush(loaded, total, true);
|
||||
if (forceRefresh) servedRefreshRef.current = refreshRequest;
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoadError(error instanceof Error ? error.message : "Erreur de chargement");
|
||||
@@ -176,7 +205,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
loadRows();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [codeFournisseur, magasin, filters.code1, filters.code2, refreshToken, setRows, isMounted]);
|
||||
}, [codeFournisseur, magasin, refreshRequest, setRows, isMounted]);
|
||||
|
||||
// Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement)
|
||||
useEffect(() => {
|
||||
@@ -193,13 +222,10 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga
|
||||
};
|
||||
|
||||
// Force le rechargement des données depuis le serveur en ignorant le cache
|
||||
// 10 min : on change le paramètre URL `_refresh`, ce qui relance le fetch
|
||||
// avec `refresh=1` (→ forceRefresh) et remonte l'état serveur à jour (INIT).
|
||||
// 10 min (`refresh=1` → forceRefresh) et remonte l'état serveur à jour (INIT).
|
||||
const handleForceRefresh = () => {
|
||||
if (isLoadingRows) return;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", String(Date.now()));
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
requestRefresh();
|
||||
};
|
||||
|
||||
if (!isMounted) {
|
||||
|
||||
@@ -104,11 +104,10 @@ export function GridFilterBar({ fournisseurs, magasins }: GridFilterBarProps) {
|
||||
router.replace(`/grid?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const requestRefresh = useGridStore((s) => s.requestRefresh);
|
||||
const handleRefresh = () => {
|
||||
setIsRefreshing(true);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", Date.now().toString());
|
||||
router.replace(`/grid?${params.toString()}`, { scroll: false });
|
||||
requestRefresh();
|
||||
// Visual feedback delay
|
||||
setTimeout(() => setIsRefreshing(false), 800);
|
||||
};
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
NB_MAGASINS_RESEAU,
|
||||
} from "@/features/grid/lib/network-trend";
|
||||
import { TrendSparkline, NetworkLineChart } from "@/features/grid/components/network-charts";
|
||||
import { fmtDecimal1, fmtEntier, fmtEur0, fmtEur2 } from "@/lib/format";
|
||||
|
||||
interface HeatmapGridProps {
|
||||
onSelectionChange?: (codeins: string[]) => void;
|
||||
@@ -94,8 +95,7 @@ function prixMoyenReseau(row: ProductRow): number | null {
|
||||
}
|
||||
|
||||
/** Montant en euros au centime — l'usage sur des prix unitaires. */
|
||||
const fmtEuro2 = (v: number) =>
|
||||
v.toLocaleString("fr-FR", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const fmtEuro2 = fmtEur2;
|
||||
|
||||
/**
|
||||
* Prix de vente à montrer pour le magasin consulté.
|
||||
@@ -315,7 +315,9 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnsKey
|
||||
data-index={virtualRow.index}
|
||||
onClick={() => row.toggleSelected()}
|
||||
className={cn(
|
||||
"absolute w-full flex items-center cursor-pointer transition-all duration-200 group/row",
|
||||
// Pas de `transition-all` : la ligne est positionnée par `transform`
|
||||
// et chaque défilement l'aurait animée pendant 200 ms.
|
||||
"absolute w-full flex items-center cursor-pointer transition-[opacity,filter] duration-200 group/row",
|
||||
effectiveGamme === "Z" && "opacity-40 grayscale-[0.5] hover:grayscale-0 hover:opacity-100"
|
||||
)}
|
||||
style={{
|
||||
@@ -625,7 +627,10 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
// totaux sont figés sur la fenêtre du serveur, les colonnes doivent l'être
|
||||
// aussi, sinon un mois manque aux cases mais pas au total (cf. months.ts).
|
||||
// Le calcul local ne sert que tant qu'aucune ligne n'est chargée.
|
||||
const MONTHS_12 = useMemo(() => getMonthsFromRows(rows) ?? getLast12Months(), [rows]);
|
||||
// Mémorisé sur sa clé texte : un nouveau tableau à chaque arrivée de lignes
|
||||
// reconstruisait toutes les définitions de colonnes (donc tout le tableau).
|
||||
const monthsKey = useMemo(() => (getMonthsFromRows(rows) ?? getLast12Months()).join(","), [rows]);
|
||||
const MONTHS_12 = useMemo(() => monthsKey.split(","), [monthsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
@@ -647,28 +652,6 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
setRowSelection({});
|
||||
}, [rows]);
|
||||
|
||||
const prevSelectionRef = useRef<string[]>([]);
|
||||
|
||||
// Propagate selection changes via useEffect to avoid "update during render" error
|
||||
useEffect(() => {
|
||||
if (!onSelectionChange) return;
|
||||
|
||||
const selectedIdxs = Object.keys(rowSelection).filter((k) => rowSelection[k]);
|
||||
const selectedCodeins = selectedIdxs
|
||||
.map((idx) => filteredData[parseInt(idx)]?.codein ?? "")
|
||||
.filter(Boolean);
|
||||
|
||||
// Only update if selection actually changed to avoid re-render loops & console warnings
|
||||
const currentString = JSON.stringify(selectedCodeins);
|
||||
const prevString = JSON.stringify(prevSelectionRef.current);
|
||||
|
||||
if (currentString !== prevString) {
|
||||
prevSelectionRef.current = selectedCodeins;
|
||||
// Delay update to next tick to ensure we're out of any render cycles
|
||||
setTimeout(() => onSelectionChange(selectedCodeins), 0);
|
||||
}
|
||||
}, [rowSelection, onSelectionChange, filteredData]);
|
||||
|
||||
const columns = React.useMemo<ColumnDef<ProductRow>[]>(() => [
|
||||
{
|
||||
id: "select",
|
||||
@@ -807,7 +790,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
const val = row.original.caReseau;
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{val != null ? val.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }) : "-"}
|
||||
{val != null ? fmtEur0(val) : "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -822,7 +805,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
const val = row.original.qteReseau;
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||
{val != null ? Math.round(val).toLocaleString("fr-FR") : "-"}
|
||||
{val != null ? fmtEntier(val) : "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -936,7 +919,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
const val = row.original.caParMagasinReseau;
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||
{val != null ? val.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }) : "-"}
|
||||
{val != null ? fmtEur0(val) : "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -954,7 +937,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
const color = pct >= 30 ? "text-emerald-500" : pct >= 15 ? "text-amber-500" : "text-rose-500";
|
||||
return (
|
||||
<div className={cn("text-center font-bold text-[12px] tabular-nums", color)}>
|
||||
{pct.toLocaleString("fr-FR", { maximumFractionDigits: 1 })}%
|
||||
{fmtDecimal1(pct)}%
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -1017,7 +1000,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
borderColor: "var(--border-strong)"
|
||||
}}
|
||||
>
|
||||
{Math.round(qty).toLocaleString("fr-FR")}
|
||||
{fmtEntier(qty)}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
@@ -1033,7 +1016,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
: (row.original.caByStore?.[activeMagasin] ?? 0);
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
|
||||
{Math.round(ca).toLocaleString("fr-FR")} €
|
||||
{fmtEntier(ca)} €
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -1054,7 +1037,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="tabular-nums text-[13px] font-bold" style={{ color: "var(--text-primary)" }}>
|
||||
{Math.round(marge).toLocaleString("fr-FR")} €
|
||||
{fmtEntier(marge)} €
|
||||
</span>
|
||||
<span className="tabular-nums text-[10px] font-bold opacity-70" style={{
|
||||
color: taux >= 40 ? "var(--accent-success)" : taux >= 25 ? "var(--accent-warning)" : "var(--accent-error)"
|
||||
@@ -1095,10 +1078,17 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
columnResizeMode: "onChange",
|
||||
// Identifiant stable : avec l'index par défaut, la sélection désignait
|
||||
// d'autres produits dès qu'un filtre changeait l'ordre des lignes.
|
||||
getRowId: (row) => row.codein,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
enableRowSelection: true,
|
||||
// La recherche ne dépend pas de la colonne (elle lit libellé, code, référence
|
||||
// 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();
|
||||
@@ -1111,6 +1101,31 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
|
||||
const { rows: tableRows } = table.getRowModel();
|
||||
|
||||
const prevSelectionRef = useRef<string[]>([]);
|
||||
|
||||
// Propagate selection changes via useEffect to avoid "update during render" error
|
||||
useEffect(() => {
|
||||
if (!onSelectionChange) return;
|
||||
|
||||
// La sélection est indexée par code article (`getRowId`), et seules les
|
||||
// lignes encore affichées comptent : une action groupée ne doit jamais
|
||||
// toucher un produit masqué par un filtre ou par la recherche.
|
||||
const selectedCodeins = table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map((r) => r.original.codein);
|
||||
|
||||
// Only update if selection actually changed to avoid re-render loops & console warnings
|
||||
const currentString = JSON.stringify(selectedCodeins);
|
||||
const prevString = JSON.stringify(prevSelectionRef.current);
|
||||
|
||||
if (currentString !== prevString) {
|
||||
prevSelectionRef.current = selectedCodeins;
|
||||
// Delay update to next tick to ensure we're out of any render cycles
|
||||
setTimeout(() => onSelectionChange(selectedCodeins), 0);
|
||||
}
|
||||
}, [table, rowSelection, onSelectionChange, filteredData, filters.search]);
|
||||
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: tableRows.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
@@ -1291,8 +1306,6 @@ export function HeatmapGrid({ onSelectionChange, isAdmin, nomFournisseur }: Heat
|
||||
<thead className="sticky top-0 z-10 block" style={{
|
||||
background: "linear-gradient(to bottom, var(--bg-elevated), var(--bg-surface))",
|
||||
borderBottom: "1px solid var(--border-strong)",
|
||||
backdropFilter: "blur(10px)",
|
||||
WebkitBackdropFilter: "blur(10px)"
|
||||
}}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="flex w-full">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
|
||||
|
||||
@@ -28,21 +27,12 @@ function formatLastUpdate(iso?: string | null): string {
|
||||
* dans `useQlikSyncJob`, partagée avec la fiche produit.
|
||||
*/
|
||||
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
/** Force le refresh de la grille (avec cache-buster) et de la barre de résumé. */
|
||||
const refreshGrid = useCallback(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", String(Date.now()));
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
router.refresh();
|
||||
}, [pathname, router, searchParams]);
|
||||
/** Recharge la grille en ignorant le cache serveur (la barre de résumé suit). */
|
||||
const requestRefresh = useGridStore((s) => s.requestRefresh);
|
||||
|
||||
const { status, message, start } = useQlikSyncJob({
|
||||
target: { mode: "fournisseur", fournisseur: codeFournisseur },
|
||||
onSuccess: refreshGrid,
|
||||
onSuccess: requestRefresh,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,6 +16,14 @@ interface GridState {
|
||||
displayDensity: "compact" | "normal" | "comfortable";
|
||||
/** The active URL search parameters (e.g. ?fournisseur=123&magasin=TOTAL) */
|
||||
activeGridQuery: string;
|
||||
/**
|
||||
* Compteur de demandes de rechargement forcé (ignore le cache serveur).
|
||||
* Volontairement hors URL et non persisté : un paramètre `_refresh` dans
|
||||
* l'URL restait mémorisé dans `activeGridQuery`, et chaque retour sur la
|
||||
* Grille par le menu relançait un recalcul complet.
|
||||
*/
|
||||
refreshRequest: number;
|
||||
requestRefresh: () => void;
|
||||
/** Persisted column visibility state */
|
||||
columnVisibility: Record<string, boolean>;
|
||||
/** Persisted column sizing state */
|
||||
@@ -59,6 +67,15 @@ interface GridState {
|
||||
|
||||
let summaryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Retire l'ancien paramètre `_refresh` (cache-buster) d'une query string mémorisée. */
|
||||
function sansParamRefresh(query: string): string {
|
||||
if (!query.includes("_refresh")) return query;
|
||||
const params = new URLSearchParams(query.startsWith("?") ? query.slice(1) : query);
|
||||
params.delete("_refresh");
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
function indexRows(rows: ProductRow[]): Record<string, ProductRow> {
|
||||
const indexed: Record<string, ProductRow> = {};
|
||||
for (const row of rows) indexed[row.codein] = row;
|
||||
@@ -117,6 +134,7 @@ export const useGridStore = create<GridState>()(
|
||||
},
|
||||
displayDensity: "normal",
|
||||
activeGridQuery: "",
|
||||
refreshRequest: 0,
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
showMonthlySales: true,
|
||||
@@ -202,7 +220,8 @@ export const useGridStore = create<GridState>()(
|
||||
set((state) => ({ ...state, filters: { ...state.filters, code3: codes } }));
|
||||
},
|
||||
setDisplayDensity: (density) => set({ displayDensity: density }),
|
||||
setActiveGridQuery: (query) => set({ activeGridQuery: query }),
|
||||
setActiveGridQuery: (query) => set({ activeGridQuery: sansParamRefresh(query) }),
|
||||
requestRefresh: () => set((state) => ({ refreshRequest: state.refreshRequest + 1 })),
|
||||
restoreSnapshot: (changes) => {
|
||||
set({ draftChanges: changes, summary: computeSummary(get().rows, changes, get().activeMagasin) });
|
||||
},
|
||||
@@ -249,8 +268,11 @@ export const useGridStore = create<GridState>()(
|
||||
* 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.
|
||||
*
|
||||
* v2 — `activeGridQuery` pouvait contenir `_refresh=…` : le lien « Grille »
|
||||
* du menu forçait alors un recalcul serveur complet à chaque retour.
|
||||
*/
|
||||
version: 1,
|
||||
version: 2,
|
||||
migrate: (persisted: unknown, version: number): GridPersisted => {
|
||||
const etat = (persisted ?? {}) as GridPersisted & { filters?: Record<string, unknown> };
|
||||
if (version < 1 && etat.filters) {
|
||||
@@ -262,6 +284,9 @@ export const useGridStore = create<GridState>()(
|
||||
// à la fusion (le bloc disparaîtrait sans raison).
|
||||
if (typeof etat.showMonthlySales !== "boolean") etat.showMonthlySales = true;
|
||||
if (typeof etat.showPrices !== "boolean") etat.showPrices = true;
|
||||
if (typeof etat.activeGridQuery === "string") {
|
||||
etat.activeGridQuery = sansParamRefresh(etat.activeGridQuery);
|
||||
}
|
||||
return etat;
|
||||
},
|
||||
// Only persist filters, display density, drafts, active grid query, and column visibility/sizing
|
||||
|
||||
@@ -27,6 +27,8 @@ export async function getSnapshots(type?: "snapshot" | "export") {
|
||||
} catch (err: any) {
|
||||
const msg = (err?.message || String(err)).split("\n")[0];
|
||||
console.error(`[getSnapshots] ERROR (type=${type}):`, msg, err);
|
||||
return [];
|
||||
// Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot »
|
||||
// au lieu de signaler que le chargement a échoué.
|
||||
throw new Error("Chargement de l'historique impossible.");
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
History,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { SuccessModal } from "@/components/shared/success-modal";
|
||||
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||
@@ -78,6 +79,7 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
|
||||
const fetchSnapshots = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getSnapshots(type);
|
||||
setSnapshots(data);
|
||||
@@ -146,6 +148,19 @@ export function SnapshotList({ type }: SnapshotListProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error && snapshots.length === 0) {
|
||||
return (
|
||||
<div className="border border-[var(--accent-error)] rounded-2xl p-10 text-center bg-[var(--accent-error-bg)]">
|
||||
<AlertCircle className="w-8 h-8 mx-auto mb-3 text-[var(--accent-error)]" />
|
||||
<h3 className="text-[var(--text-primary)] font-bold mb-1">L'historique n'a pas pu être chargé</h3>
|
||||
<p className="text-[var(--text-secondary)] text-sm mb-4">{error}</p>
|
||||
<button onClick={fetchSnapshots} className="btn-action btn-action-secondary">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshots.length === 0) {
|
||||
return (
|
||||
<div className="border border-dashed border-[var(--border-strong)] rounded-2xl p-16 text-center bg-[var(--bg-elevated)]/30">
|
||||
|
||||
@@ -16,6 +16,15 @@ const CONFIG_FILE = path.join(process.cwd(), "data", ".db-config.json");
|
||||
const FF_API_BASE_DEFAULT = "https://api.ffnancy.fr";
|
||||
const FF_API_BASE_TTL_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Options de tout appel à l'API FF : jamais de cache HTTP, et un délai maximal.
|
||||
* Sans délai, une API muette figeait indéfiniment la page qui l'attendait.
|
||||
*/
|
||||
const FF_FETCH_TIMEOUT_MS = Number(process.env.FF_API_TIMEOUT_MS) || 30_000;
|
||||
export function ffFetchInit(): RequestInit {
|
||||
return { cache: "no-store", signal: AbortSignal.timeout(FF_FETCH_TIMEOUT_MS) };
|
||||
}
|
||||
|
||||
let ffApiBaseCache: { value: string; at: number } | null = null;
|
||||
|
||||
/**
|
||||
@@ -201,7 +210,7 @@ async function fetchAllPages<T>(
|
||||
// Page 1 — récupère les items et tente d'obtenir le total pour paralléliser
|
||||
const url1 = buildUrl(1);
|
||||
console.log(`[api-ff] GET ${url1}`);
|
||||
const res1 = await fetch(url1, { cache: "no-store" });
|
||||
const res1 = await fetch(url1, ffFetchInit());
|
||||
if (!res1.ok) {
|
||||
console.error(`[api-ff] HTTP ${res1.status} — ${url1}`);
|
||||
return [];
|
||||
@@ -227,7 +236,7 @@ async function fetchAllPages<T>(
|
||||
const results = await Promise.all(chunk.map(async (p) => {
|
||||
const url = buildUrl(p);
|
||||
console.log(`[api-ff] GET ${url}`);
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
const res = await fetch(url, ffFetchInit());
|
||||
if (!res.ok) return [] as T[];
|
||||
const data = await res.json();
|
||||
return extractList(data) as T[];
|
||||
@@ -245,7 +254,7 @@ async function fetchAllPages<T>(
|
||||
const results = await Promise.all(chunk.map(async (p) => {
|
||||
const url = buildUrl(p);
|
||||
console.log(`[api-ff] GET ${url}`);
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
const res = await fetch(url, ffFetchInit());
|
||||
if (!res.ok) return { p, items: [] as T[] };
|
||||
const data = await res.json();
|
||||
return { p, items: extractList(data) as T[] };
|
||||
@@ -311,7 +320,7 @@ export async function getFournisseursFromApi(
|
||||
: `${FF_API_BASE}/api/fournisseurs?limit=500`;
|
||||
|
||||
console.log(`[api-ff] GET ${url}`);
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
const res = await fetch(url, ffFetchInit());
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} on ${url}`);
|
||||
const data = await res.json();
|
||||
|
||||
@@ -468,7 +477,7 @@ export async function getMensuelByArticles(
|
||||
if (!noId) return;
|
||||
try {
|
||||
const url = `${FF_API_BASE}/api/articles/${encodeURIComponent(String(noId))}/mensuel?dateDebut=${dateDebut}&dateFin=${dateFin}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
const res = await fetch(url, ffFetchInit());
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const entries: FfMensuelEntry[] = Array.isArray(data.data) ? data.data : [];
|
||||
@@ -555,7 +564,7 @@ export async function getReferentielByArticles(
|
||||
if (!noId) return;
|
||||
try {
|
||||
const url = `${FF_API_BASE}/api/articles/${encodeURIComponent(String(noId))}/referentiel`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
const res = await fetch(url, ffFetchInit());
|
||||
if (!res.ok) return;
|
||||
const data: FfReferentiel = await res.json();
|
||||
if (data?.article) result.set(art.codein, data);
|
||||
@@ -581,7 +590,7 @@ export async function getCommandesByFournisseur(
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${FF_API_BASE}/api/commandes/articles?codefou=${encodeURIComponent(codefou)}`,
|
||||
{ cache: "no-store" }
|
||||
ffFetchInit()
|
||||
);
|
||||
if (!res.ok) return result;
|
||||
const data = await res.json();
|
||||
@@ -602,7 +611,7 @@ export async function getCommandesByFournisseur(
|
||||
export async function getSyncStatus(): Promise<FfSyncStatus | null> {
|
||||
try {
|
||||
const FF_API_BASE = await getFfApiBase();
|
||||
const res = await fetch(`${FF_API_BASE}/api/sync/status`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/sync/status`, ffFetchInit());
|
||||
if (!res.ok) return null;
|
||||
return normalizeSyncStatus(await res.json());
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* CollectFlow — Cache mémoire des lectures « FF » (base miroir et API FF Nancy).
|
||||
*
|
||||
* Les tables FF (`mvtart`, `cube_stock`, `articles`…) ne sont recopiées qu'une
|
||||
* fois par nuit depuis SQL Server : recalculer à chaque visite les mêmes
|
||||
* agrégations lourdes (stock, hit-parade, CA mensuel…) coûtait plusieurs
|
||||
* secondes par page pour un résultat identique.
|
||||
*
|
||||
* Pourquoi pas `unstable_cache` : il sérialise en JSON (une `Map` devient `{}`,
|
||||
* une `Date` une chaîne) et ne peut pas être invalidé hors d'une requête — or
|
||||
* c'est la synchro nocturne, lancée par un minuteur, qui sait quand les données
|
||||
* changent. Ici :
|
||||
* - une durée de vie bornée (FF_CACHE_TTL_S, 30 min par défaut) ;
|
||||
* - les appels simultanés pour la même clé partagent une seule requête ;
|
||||
* - une erreur n'est jamais mise en cache ;
|
||||
* - `invalidateFFCache()` vide tout (appelé en fin de synchro nocturne).
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { pgGetDerniereReceptionParFournisseur, pgGetFournisseurs } from "@/lib/pg-ff-client";
|
||||
|
||||
const TTL_MS = (Number(process.env.FF_CACHE_TTL_S) || 30 * 60) * 1000;
|
||||
/** Plafond d'entrées : les clés incluent des paramètres (dates, mois, magasin). */
|
||||
const MAX_ENTRIES = 100;
|
||||
|
||||
interface Entry {
|
||||
value?: unknown;
|
||||
expiresAt: number;
|
||||
pending?: Promise<unknown>;
|
||||
}
|
||||
|
||||
// Rangé dans globalThis : un seul cache par processus, même si ce module est
|
||||
// présent dans plusieurs lots compilés (routes, instrumentation).
|
||||
const globalForFFCache = globalThis as typeof globalThis & {
|
||||
__collectflowFFCache?: { store: Map<string, Entry>; generation: number };
|
||||
};
|
||||
const cache = (globalForFFCache.__collectflowFFCache ??= { store: new Map(), generation: 0 });
|
||||
const store = cache.store;
|
||||
|
||||
interface CachedOptions<T> {
|
||||
/** Durée de vie propre à cette clé, en millisecondes. */
|
||||
ttlMs?: number;
|
||||
/**
|
||||
* Ne garder le résultat que s'il passe ce test. Sert aux lectures qui
|
||||
* avalent leurs erreurs et renvoient une liste vide : sans ce garde-fou,
|
||||
* une panne passagère resterait affichée pendant toute la durée de vie.
|
||||
*/
|
||||
cacheIf?: (value: T) => boolean;
|
||||
}
|
||||
|
||||
export async function cachedFF<T>(key: string, load: () => Promise<T>, options: CachedOptions<T> = {}): Promise<T> {
|
||||
const now = Date.now();
|
||||
const entry = store.get(key);
|
||||
if (entry && "value" in entry && entry.expiresAt > now) return entry.value as T;
|
||||
if (entry?.pending) return entry.pending as Promise<T>;
|
||||
|
||||
// Une invalidation pendant le chargement rend son résultat suspect : il est
|
||||
// alors renvoyé à l'appelant, mais pas conservé.
|
||||
const generation = cache.generation;
|
||||
const pending = load().then(
|
||||
(value) => {
|
||||
if (generation !== cache.generation) return value;
|
||||
if (options.cacheIf && !options.cacheIf(value)) {
|
||||
store.delete(key);
|
||||
} else {
|
||||
store.delete(key); // réinsertion en fin : ordre d'éviction = ancienneté
|
||||
store.set(key, { value, expiresAt: Date.now() + (options.ttlMs ?? TTL_MS) });
|
||||
while (store.size > MAX_ENTRIES) {
|
||||
const oldest = store.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
store.delete(oldest);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
(error) => {
|
||||
if (generation === cache.generation) store.delete(key);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
// Une éventuelle valeur expirée est remplacée : seule la requête en cours compte.
|
||||
store.set(key, { expiresAt: 0, pending });
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Vide tout le cache FF (fin de synchro nocturne, données fraîches). */
|
||||
export function invalidateFFCache(): void {
|
||||
cache.generation++;
|
||||
store.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lectures partagées par plusieurs pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Référentiel fournisseurs (Grille, cadencier). Vide = erreur avalée, non gardé. */
|
||||
export function getFournisseursCached(): Promise<{ code: string; nom: string }[]> {
|
||||
return cachedFF("fournisseurs", () => pgGetFournisseurs(), { cacheIf: (rows) => rows.length > 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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é.
|
||||
*/
|
||||
export function getDerniereReceptionCached(): Promise<Map<string, string>> {
|
||||
return cachedFF("derniere-reception", () => pgGetDerniereReceptionParFournisseur(), {
|
||||
cacheIf: (map) => map.size > 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Formateurs de nombres partagés (fr-FR).
|
||||
*
|
||||
* `Number.prototype.toLocaleString` appelé avec des options construit un nouvel
|
||||
* `Intl.NumberFormat` à chaque appel : dans la Grille, plusieurs milliers de
|
||||
* cellules le payaient à chaque défilement et à chaque tri. Ces instances sont
|
||||
* créées une seule fois et donnent exactement le même texte.
|
||||
*/
|
||||
|
||||
const nfEntier = new Intl.NumberFormat("fr-FR");
|
||||
const nfDecimal1 = new Intl.NumberFormat("fr-FR", { maximumFractionDigits: 1 });
|
||||
const nfEur0 = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
|
||||
const nfEur2 = new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
/** Entier arrondi avec séparateur de milliers : `12 345`. */
|
||||
export const fmtEntier = (v: number): string => nfEntier.format(Math.round(v));
|
||||
|
||||
/** Nombre avec au plus une décimale : `12,5`. */
|
||||
export const fmtDecimal1 = (v: number): string => nfDecimal1.format(v);
|
||||
|
||||
/** Montant en euros sans centimes : `12 345 €`. */
|
||||
export const fmtEur0 = (v: number): string => nfEur0.format(v);
|
||||
|
||||
/** Montant en euros au centime (prix unitaires) : `3,49 €`. */
|
||||
export const fmtEur2 = (v: number): string => nfEur2.format(v);
|
||||
+32
-1
@@ -14,7 +14,7 @@ import "server-only";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { gridRows } from "@/db/schema";
|
||||
import { and, asc, desc, eq, ilike, or, sql, lt, type SQL } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, ilike, inArray, or, sql, lt, type SQL } from "drizzle-orm";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
/** Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute. */
|
||||
@@ -147,6 +147,37 @@ export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[]
|
||||
return written;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function updateGridRowsGamme(
|
||||
codeFournisseur: string,
|
||||
changes: ReadonlyArray<{ codein: string; codeGamme: string }>,
|
||||
): Promise<void> {
|
||||
const parGamme = new Map<string, string[]>();
|
||||
for (const c of changes) {
|
||||
const codeins = parGamme.get(c.codeGamme) ?? [];
|
||||
codeins.push(c.codein);
|
||||
parGamme.set(c.codeGamme, codeins);
|
||||
}
|
||||
const CHUNK = 5000; // bien en deçà des 65 535 paramètres liés de PostgreSQL
|
||||
for (const [gamme, codeins] of parGamme) {
|
||||
for (let i = 0; i < codeins.length; i += CHUNK) {
|
||||
await db
|
||||
.update(gridRows)
|
||||
.set({
|
||||
codeGamme: gamme,
|
||||
payload: sql`jsonb_set(${gridRows.payload}, '{codeGamme}', to_jsonb(${gamme}::text))`,
|
||||
})
|
||||
.where(and(
|
||||
eq(gridRows.codeFournisseur, codeFournisseur),
|
||||
inArray(gridRows.codein, codeins.slice(i, i + CHUNK)),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Construit la clause WHERE commune à la grille et à la recherche transversale. */
|
||||
function buildWhere(q: GridQuery): SQL | undefined {
|
||||
const clauses: SQL[] = [];
|
||||
|
||||
+18
-4
@@ -22,7 +22,7 @@
|
||||
* un job qui fait tomber le process laisse quand même son journal.
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { appendFile, mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -35,7 +35,7 @@ const MAX_LIGNES = 200_000;
|
||||
const MAX_LONGUEUR_LIGNE = 8_000;
|
||||
/** Nombre de journaux conservés sur disque. */
|
||||
const MAX_FICHIERS = 20;
|
||||
/** Écriture disque au plus toutes les N ms (le journal complet est réécrit). */
|
||||
/** Écriture disque au plus toutes les N ms (seules les nouvelles lignes sont ajoutées). */
|
||||
const FLUSH_MS = 2_000;
|
||||
|
||||
interface Capture {
|
||||
@@ -46,6 +46,8 @@ interface Capture {
|
||||
flushEnCours: boolean;
|
||||
flushDemande: boolean;
|
||||
dernierFlush: number;
|
||||
/** Nombre de lignes déjà écrites sur disque. */
|
||||
ecrites: number;
|
||||
}
|
||||
|
||||
const captures = new Map<string, Capture>();
|
||||
@@ -82,7 +84,16 @@ async function flush(capture: Capture): Promise<void> {
|
||||
capture.flushEnCours = true;
|
||||
try {
|
||||
await mkdir(LOG_DIR, { recursive: true });
|
||||
await writeFile(fichierPour(capture.id), capture.lignes.join("\n") + "\n", "utf8");
|
||||
// Ajout des seules nouvelles lignes : réécrire tout le journal (jusqu'à
|
||||
// 200 000 lignes) toutes les 2 s bloquait le serveur pendant les synchros.
|
||||
const total = capture.lignes.length;
|
||||
const nouvelles = capture.lignes.slice(capture.ecrites, total);
|
||||
if (capture.ecrites === 0) {
|
||||
await writeFile(fichierPour(capture.id), nouvelles.join("\n") + "\n", "utf8");
|
||||
} else if (nouvelles.length > 0) {
|
||||
await appendFile(fichierPour(capture.id), nouvelles.join("\n") + "\n", "utf8");
|
||||
}
|
||||
capture.ecrites = total;
|
||||
capture.dernierFlush = Date.now();
|
||||
} catch {
|
||||
// Un journal de diagnostic ne doit jamais faire échouer le job qu'il observe.
|
||||
@@ -128,7 +139,9 @@ function instrumenterConsole(): void {
|
||||
const origine = console[methode].bind(console);
|
||||
console[methode] = (...args: unknown[]) => {
|
||||
try {
|
||||
ajouter(formater(args));
|
||||
// Aucune capture ouverte (le cas courant) : on ne formate rien.
|
||||
// Sérialiser chaque argument de chaque log coûtait à toutes les requêtes.
|
||||
if (captures.size > 0) ajouter(formater(args));
|
||||
} catch {
|
||||
// idem : jamais au détriment du log réel
|
||||
}
|
||||
@@ -173,6 +186,7 @@ export function startCapture(id: string, entete?: string): void {
|
||||
flushEnCours: false,
|
||||
flushDemande: false,
|
||||
dernierFlush: 0,
|
||||
ecrites: 0,
|
||||
};
|
||||
captures.set(id, capture);
|
||||
capture.lignes.push(`[${horodatage()}] [log-capture] début de capture ${id}`);
|
||||
|
||||
+14
-22
@@ -9,6 +9,7 @@
|
||||
|
||||
import { db } from "@/db";
|
||||
import { sql, type SQL } from "drizzle-orm";
|
||||
import { ffFetchInit } from "@/lib/api-ff-client";
|
||||
|
||||
const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr";
|
||||
|
||||
@@ -20,16 +21,13 @@ let _nomenclatureParentCol: string | null | undefined = undefined; // undefined
|
||||
// Utilitaire : exécuter une requête sans workers parallèles PostgreSQL.
|
||||
// Les workers parallèles consomment /dev/shm (mémoire partagée Docker) et font
|
||||
// échouer les requêtes si le conteneur a un shm_size insuffisant.
|
||||
// SET LOCAL s'applique uniquement à la transaction courante.
|
||||
// Le réglage est désormais posé une fois par connexion du pool (src/db/index.ts) :
|
||||
// la transaction BEGIN / SET LOCAL / COMMIT qui entourait chaque requête coûtait
|
||||
// trois allers-retours de plus à chacune.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function pgNoParallel(query: SQL): Promise<{ rows: unknown[] }> {
|
||||
let rows: unknown[] = [];
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`SET LOCAL max_parallel_workers_per_gather = 0`);
|
||||
const r = await tx.execute(query);
|
||||
rows = r.rows;
|
||||
});
|
||||
return { rows };
|
||||
const r = await db.execute(query);
|
||||
return { rows: r.rows };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -96,7 +94,6 @@ export async function pgGetFournisseurs(search?: string): Promise<{ code: string
|
||||
${search ? sql`AND (fi.nom ILIKE ${'%' + search + '%'} OR fi.code ILIKE ${'%' + search + '%'})` : sql``}
|
||||
ORDER BY fi.nom
|
||||
`);
|
||||
console.log(`[pg-ff] pgGetFournisseurs: ${result.rows.length} fournisseurs`);
|
||||
return (result.rows as unknown as { code: string; nom: string }[]).filter(r => r.code && r.nom);
|
||||
} catch (e) {
|
||||
console.error("[pg-ff] pgGetFournisseurs error:", (e as Error).message?.slice(0, 200));
|
||||
@@ -297,10 +294,6 @@ export async function pgGetGammesByFournisseur(codefou: string): Promise<Map<str
|
||||
for (const row of result.rows as unknown as { codein: string; gamme_code: string }[]) {
|
||||
if (row.codein && row.gamme_code) map.set(row.codein, String(row.gamme_code).trim());
|
||||
}
|
||||
if (result.rows.length > 0) {
|
||||
console.log("[pg-ff] Gammes sample row:", JSON.stringify(result.rows[0]));
|
||||
}
|
||||
console.log(`[pg-ff] Gammes: ${map.size} articles avec gamme`);
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -429,7 +422,6 @@ export async function pgGetNomenclatureByFournisseur(codefou: string): Promise<M
|
||||
}
|
||||
|
||||
console.log(`[pg-ff] Nomenclature: ${result.rows.length} articles, parentCol="${parentCol ?? "none"}"`);
|
||||
if (result.rows.length > 0) console.log("[pg-ff] Nomenclature sample:", JSON.stringify(result.rows[0]));
|
||||
return buildNomMap(result.rows as unknown as PgNomRow[]);
|
||||
}
|
||||
|
||||
@@ -635,9 +627,9 @@ export async function pgGetDashboardData(): Promise<DashboardData> {
|
||||
|
||||
// Deux appels parallèles : données hier + données même jour N-1
|
||||
const [apiResult, apiN1Result] = await Promise.all([
|
||||
fetch(`${apiBase}/dashboard?date=${dateHier}`, { cache: "no-store" })
|
||||
fetch(`${apiBase}/dashboard?date=${dateHier}`, ffFetchInit())
|
||||
.then(r => { if (!r.ok) throw new Error(`API dashboard ${r.status}: ${r.statusText}`); return r.json() as Promise<ApiPerfDashboard>; }),
|
||||
fetch(`${apiBase}/dashboard?date=${dateN1Param}`, { cache: "no-store" })
|
||||
fetch(`${apiBase}/dashboard?date=${dateN1Param}`, ffFetchInit())
|
||||
.then(r => r.ok ? r.json() as Promise<ApiPerfDashboard> : null)
|
||||
.catch(() => null),
|
||||
]);
|
||||
@@ -686,7 +678,7 @@ export async function pgGetDashboardData(): Promise<DashboardData> {
|
||||
const batch = allCodeins.slice(i, i + 5);
|
||||
await Promise.all(batch.map(async (codein) => {
|
||||
try {
|
||||
const artRes = await fetch(`${FF_API_BASE}/api/articles?codein=${encodeURIComponent(codein)}`);
|
||||
const artRes = await fetch(`${FF_API_BASE}/api/articles?codein=${encodeURIComponent(codein)}`, ffFetchInit());
|
||||
if (!artRes.ok) return;
|
||||
const artData = await artRes.json();
|
||||
if (!artData.articles || artData.articles.length === 0) return;
|
||||
@@ -695,8 +687,8 @@ export async function pgGetDashboardData(): Promise<DashboardData> {
|
||||
if (!noId) return;
|
||||
|
||||
const [refRes, dernierRes] = await Promise.all([
|
||||
fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`),
|
||||
fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/dernier-fournisseur`),
|
||||
fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/referentiel`, ffFetchInit()),
|
||||
fetch(`${FF_API_BASE}/api/articles/${encodeURIComponent(noId)}/dernier-fournisseur`, ffFetchInit()),
|
||||
]);
|
||||
|
||||
// Stock (toujours depuis referentiel)
|
||||
@@ -1373,7 +1365,7 @@ function mapCommandeAutoRow(r: any): PgCommandeAutoRow {
|
||||
export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
|
||||
|
||||
try {
|
||||
const res = await fetch(`${FF_API_BASE}/api/commandes-auto`, { cache: "no-store" });
|
||||
const res = await fetch(`${FF_API_BASE}/api/commandes-auto`, ffFetchInit());
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
// L'API retourne { count: N, propositions: [...] } ou un tableau direct
|
||||
@@ -1414,7 +1406,7 @@ export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
|
||||
uniqueCodeFous.map(async codefou => {
|
||||
// 1. Essai sans filtre site
|
||||
try {
|
||||
const res2 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}`, { cache: "no-store" });
|
||||
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);
|
||||
@@ -1423,7 +1415,7 @@ export async function pgGetCommandesAuto(): Promise<PgCommandeAutoRow[]> {
|
||||
} catch { /* ignore */ }
|
||||
// 2. Fallback site=000
|
||||
try {
|
||||
const res3 = await fetch(`${FF_API_BASE}/api/commandes-auto/${encodeURIComponent(codefou)}?site=000`, { cache: "no-store" });
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user