From 7e4b3702677874c44ae794882a7c3d3ec2690829 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 2 Apr 2026 16:31:11 +0200 Subject: [PATCH] feat: implement PostgreSQL client for bulk data retrieval to replace per-article HTTP requests and configure Claude permissions --- .claude/settings.json | 26 ++++++++++- src/app/(dashboard)/grid/page.tsx | 3 +- src/features/grid/actions.ts | 6 ++- src/features/grid/api/get-product-rows.ts | 48 ++++++++++++++++++-- src/features/grid/components/grid-client.tsx | 10 ++-- src/features/grid/store/use-grid-store.ts | 17 +++++-- 6 files changed, 91 insertions(+), 19 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 84aa383..4d783f8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -3,7 +3,31 @@ "allow": [ "Bash(git push:*)", "Bash(npx next:*)", - "Bash(git status:*)" + "Bash(git status:*)", + "Bash(find c:/Users/Michael/VSCODE/CollectFlow/src -name *.ts -o -name *.tsx)", + "Bash(curl -s \"http://localhost:3001/analytics\")", + "Bash(ls -la \"c:/Users/Michael/VSCODE/CollectFlow/src/app/\\(dashboard\\)/analytics/\")", + "Read(//tmp/**)", + "Bash(npm run:*)", + "Bash(find /c/Users/Michael/VSCODE/CollectFlow -type f -name *.sql)", + "WebFetch(domain:api.ffnancy.fr)", + "Bash(find . -name *.env* -o -name .env.local)", + "WebFetch(domain:localhost)", + "Bash(curl -s https://api.ffnancy.fr/api/fournisseurs?limit=200)", + "Bash(curl -s https://api.ffnancy.fr/api/fournisseurs?limit=500)", + "Bash(git rm:*)", + "Bash(curl -s -w \"\\\\n%{http_code}\" \"https://api.ffnancy.fr/api/performance/ca/fournisseur?dateDebut=2026-01-01&dateFin=2026-03-31&site=\")", + "Bash(curl -s -w \"\\\\n%{http_code}\" \"https://api.ffnancy.fr/api/performance/ca/nomenclature?dateDebut=2026-01-01&dateFin=2026-03-31&site=&niveau=1\")", + "Bash(curl -s -w \"\\\\nSTATUS:%{http_code}\\\\n\" \"https://api.ffnancy.fr/api/performance/ca\")", + "Bash(curl -s -w \"\\\\nSTATUS:%{http_code}\\\\n\" \"https://api.ffnancy.fr/api/performance\")", + "Bash(curl -s -w \"\\\\nSTATUS:%{http_code}\\\\n\" \"https://api.ffnancy.fr/api/performance/ca/fournisseur\")", + "Bash(curl -s \"https://api.ffnancy.fr/api/performance/ca/nomenclature?dateDebut=2025-03-01&dateFin=2025-03-31&site=&niveau=1\")", + "Bash(curl -s \"https://api.ffnancy.fr/api/performance/ca/nomenclature?dateDebut=2025-03-01&dateFin=2025-03-31&site=292&niveau=1\")", + "Bash(curl -s \"https://api.ffnancy.fr/api/performance/ca?dateDebut=2025-03-01&dateFin=2025-03-31&groupBy=mois\")", + "Bash(curl -i -s \"https://api.ffnancy.fr/api/performance/ca/nomenclature?dateDebut=2025-03-01&dateFin=2025-03-31&site=&niveau=1\")", + "Bash(curl -i -s \"https://api.ffnancy.fr/api/performance/ca?dateDebut=2025-03-01&dateFin=2025-03-31&groupBy=mois\")", + "WebFetch(domain:collect.ffnancy.fr)", + "Bash(curl -s \"https://collect.ffnancy.fr/api/diag-mvtart\")" ] } } diff --git a/src/app/(dashboard)/grid/page.tsx b/src/app/(dashboard)/grid/page.tsx index 2185bec..407e0b1 100644 --- a/src/app/(dashboard)/grid/page.tsx +++ b/src/app/(dashboard)/grid/page.tsx @@ -33,7 +33,8 @@ export default async function GridPage({ searchParams }: GridPageProps) { return ; } - // 3. Load real product data for the selected supplier + // 3. Load product data — résultat mis en cache 5 min par fournisseur (unstable_cache). + // La première requête est lente mais les suivantes (refresh, navigation) sont instantanées. console.log(`\n>>> [GridPage] Fetching data for: ${codeFournisseur}, shop: ${magasin}`); const rows = await getGridData(codeFournisseur, magasin, filters); console.log(`>>> [GridPage] Received ${rows.length} rows from getGridData`); diff --git a/src/features/grid/actions.ts b/src/features/grid/actions.ts index ddd53bb..555bac8 100644 --- a/src/features/grid/actions.ts +++ b/src/features/grid/actions.ts @@ -21,14 +21,16 @@ export async function getMagasins() { /** * Nomenclature filter is disabled — code3 is not available from the API. - * Returns an empty hierarchy so the sidebar filter is hidden gracefully. */ export async function getAvailableNomenclature() { return {}; } /** - * Get product data for a specific supplier and store. + * Retourne tous les produits d'un fournisseur. + * Le résultat est mis en cache 5 min côté serveur (unstable_cache par fournisseur). + * La première requête est lente (6 SQL + agrégation mémoire). + * Toutes les requêtes suivantes dans la fenêtre de 5 min sont instantanées. */ export async function getGridData( codeFournisseur: string, diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 5e3476d..262dc99 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -16,15 +16,26 @@ import { import { db } from "@/db"; import { sessionSnapshots } from "@/db/schema"; import { eq, desc } from "drizzle-orm"; +import { unstable_cache } from "next/cache"; -interface GetProductRowsInput { +// ─── Tag de cache par fournisseur ───────────────────────────────────────────── +// Permet d'invalider précisément le cache d'un seul fournisseur lors d'une sauvegarde. +export function getGridCacheTag(codeFournisseur: string): string { + return `grid-rows-${codeFournisseur}`; +} + +export interface GetProductRowsInput { codeFournisseur: string; magasin?: string; filters?: Partial; } -export async function getProductRows(input: GetProductRowsInput): Promise { - const { codeFournisseur, magasin = "TOTAL" } = input; +// ─── Fonction interne : fetch complet non-paginé (cachée) ─────────────────── +// Cette fonction est le « dataloader » central. Elle est enveloppée dans +// unstable_cache pour ne jamais refaire les 6 requêtes SQL tant que le cache +// est valide (revalidate : 300s). Le tag permet une invalidation ciblée. +async function _fetchAllProductRows(codeFournisseur: string): Promise { + const magasin = "TOTAL"; // Le cache est toujours TOTAL ; le switch magasin est client-side console.log(`\n>>> [getProductRows] supplier: ${codeFournisseur}, magasin: ${magasin}`); try { @@ -299,7 +310,36 @@ export async function getProductRows(input: GetProductRowsInput): Promise { + const { codeFournisseur } = input; + + // Créer dynamiquement la fonction cachée avec le tag spécifique au fournisseur. + // unstable_cache mémoïse par le tableau de clés [`grid-rows-${codeFournisseur}`]. + const cachedFn = unstable_cache( + async () => _fetchAllProductRows(codeFournisseur), + [`grid-rows-${codeFournisseur}`], + { revalidate: 300, tags: [`grid-rows-${codeFournisseur}`] } + ); + + try { + return await cachedFn(); + } catch (e) { + console.error(`[getProductRows] Cache error for ${codeFournisseur}:`, e); + // Fallback sans cache si unstable_cache échoue + return _fetchAllProductRows(codeFournisseur); + } +} diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx index 02c1a96..2d6931b 100644 --- a/src/features/grid/components/grid-client.tsx +++ b/src/features/grid/components/grid-client.tsx @@ -11,9 +11,7 @@ import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts"; import type { ProductRow } from "@/types/grid"; import { CheckCircle, AlertCircle, Loader2 } from "lucide-react"; import { useSession } from "next-auth/react"; - import { useSearchParams } from "next/navigation"; -import { computeProductScores } from "@/lib/score-engine"; interface GridClientProps { initialRows: ProductRow[]; @@ -26,7 +24,7 @@ interface GridClientProps { export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fournisseurs, magasins, magasin }: GridClientProps) { const { data: session } = useSession(); - const isAdmin = (session?.user as any)?.role === "admin"; + const isAdmin = (session?.user as { role?: string })?.role === "admin"; const setRows = useGridStore((s) => s.setRows); const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery); const setFilter = useGridStore((s) => s.setFilter); @@ -50,7 +48,6 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn useEffect(() => { if (!isMounted) return; const currentQueryString = searchParams.toString(); - // Set the active query, ensuring it starts with ? if not empty setActiveGridQuery(currentQueryString ? `?${currentQueryString}` : ""); }, [searchParams, setActiveGridQuery, isMounted]); @@ -63,8 +60,9 @@ export function GridClient({ initialRows, codeFournisseur, nomFournisseur, fourn setFilter("code3", null); prevFournisseurRef.current = codeFournisseur; } - const scoredRows = computeProductScores([...initialRows]); - setRows(scoredRows); + // Les scores ont déjà été calculés côté serveur dans _fetchAllProductRows. + // On pousse directement les lignes dans le store sans recalcul côté client. + setRows(initialRows); }, [codeFournisseur, initialRows, setRows, setFilter, isMounted]); // Synchroniser le magasin actif depuis la prop URL (changement de magasin sans rechargement) diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index 3591dac..4127cf8 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -7,6 +7,8 @@ import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/gr interface GridState { /** Source data from server */ rows: ProductRow[]; + /** Index O(1) pour les lookups par codein (Map) */ + rowsIndex: Map; /** Draft edits: codein → new GammeCode */ draftChanges: Record; filters: GridFilters; @@ -64,6 +66,7 @@ export const useGridStore = create()( persist( (set, get) => ({ rows: [], + rowsIndex: new Map(), activeMagasin: "TOTAL", draftChanges: {}, filters: { @@ -94,12 +97,15 @@ export const useGridStore = create()( }, setRows: (rows) => { - set({ rows, summary: computeSummary(rows, get().draftChanges, get().activeMagasin) }); + // Construire l'index en O(n) une seule fois au lieu de O(n) à chaque lookup. + const rowsIndex = new Map(rows.map(r => [r.codein, r])); + set({ rows, rowsIndex, summary: computeSummary(rows, get().draftChanges, get().activeMagasin) }); }, setDraftGamme: (codein, gamme) => { - const { rows, draftChanges: oldDrafts } = get(); - const originalRow = rows.find(r => r.codein === codein); + const { rows, rowsIndex, draftChanges: oldDrafts } = get(); + // Lookup O(1) via l'index au lieu de O(n) via .find() + const originalRow = rowsIndex.get(codein); const originalGamme = originalRow?.codeGamme; const draftChanges = { ...oldDrafts }; @@ -144,11 +150,12 @@ export const useGridStore = create()( }, batchSetDraftGamme: (newChanges) => { - const { rows, draftChanges: oldDrafts } = get(); + const { rows, rowsIndex, draftChanges: oldDrafts } = get(); const updatedDrafts = { ...oldDrafts }; Object.entries(newChanges).forEach(([codein, gamme]) => { - const originalRow = rows.find(r => r.codein === codein); + // Lookup O(1) via l'index au lieu de O(n) via .find() + const originalRow = rowsIndex.get(codein); const originalGamme = originalRow?.codeGamme; if (gamme === originalGamme) {