From 753b301066207739045c3e1dc948456adbc5b510 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 14 Aug 2026 11:55:49 +0200 Subject: [PATCH] perf: add missing indexes and remove N+1 queries The app degraded progressively over a year of data growth. Four causes, all cumulative: 1. No indexes. Apart from primary keys, unique constraints and session.expire, no table carried an index. PostgreSQL does not index foreign keys automatically, so every filter and join on group_id, supplier_id, order_id and the date columns did a sequential scan. Worst offender: user_groups.user_id, read by getUserWithGroups() on every authenticated request. 2. N+1 in the order and delivery listings. getOrders, getOrdersByDateRange, getDeliveries and getDeliveriesByDateRange issued one to two queries per row to load relations. Relations are now loaded in bulk and grouped in Node: three queries regardless of volume. 3. /api/sync-order-delivery-status reloaded the whole deliveries table on every iteration of its loop over orders. It now uses the deliveries getOrders() already attaches. 4. clearExpiredCache() was implemented but never called, so invoice_verification_cache grew without bound. Now scheduled every 6h. Also replaces the full-history downloads on the Groups and Suppliers pages, which fetched every order and delivery with nested relations only to count rows, with aggregate endpoints that count in the database. Indexes are created via scripts/auto-migrate-production.sh, the script that actually runs at deploy time, using CREATE INDEX CONCURRENTLY so no write lock is taken. Note that server/migrations.ts explicitly ignores the migrations/ directory and runs only hardcoded migrations; the SQL file added there is for reference and manual application. Verified: typecheck baseline 440 errors, 430 after, none new in the changed code; vite build passes; server boots; functional test confirms the aggregate endpoints match the source data including the delivered count. Co-Authored-By: Claude Fable 5 --- client/src/pages/Groups.tsx | 53 +- client/src/pages/Suppliers.tsx | 26 +- .../20260814_add_performance_indexes.sql | 109 ++++ scripts/auto-migrate-production.sh | 133 ++++- server/index.ts | 4 + server/maintenance.ts | 27 + server/routes.ts | 68 ++- server/storage.ts | 539 ++++++++++-------- shared/schema.ts | 9 + 9 files changed, 678 insertions(+), 290 deletions(-) create mode 100644 migrations/20260814_add_performance_indexes.sql create mode 100644 server/maintenance.ts diff --git a/client/src/pages/Groups.tsx b/client/src/pages/Groups.tsx index a66fd16..8b80e95 100644 --- a/client/src/pages/Groups.tsx +++ b/client/src/pages/Groups.tsx @@ -18,7 +18,7 @@ import { Truck, Palette } from "lucide-react"; -import type { Group, NocodbConfig } from "@shared/schema"; +import type { EntityActivityStats, Group, NocodbConfig } from "@shared/schema"; const colorOptions = [ { value: '#1976D2', label: 'Bleu' }, @@ -77,12 +77,10 @@ export default function Groups() { environment: window.location.hostname }); - const { data: orders = [] } = useQuery({ - queryKey: ['/api/orders'], - }); - - const { data: deliveries = [] } = useQuery({ - queryKey: ['/api/deliveries'], + // Comptages agrégés en base : la page n'affiche que des totaux, inutile de + // rapatrier l'historique complet des commandes et des livraisons. + const { data: groupStats = [] } = useQuery({ + queryKey: ['/api/stats/by-group'], }); const createMutation = useMutation({ @@ -214,42 +212,15 @@ export default function Groups() { group.name.toLowerCase().includes(searchTerm.toLowerCase()) ) : []; - console.log('🏪 Groups Debug:', { - groups: Array.isArray(groups) ? groups.length : 'NOT_ARRAY', - orders: Array.isArray(orders) ? orders.length : 'NOT_ARRAY', - deliveries: Array.isArray(deliveries) ? deliveries.length : 'NOT_ARRAY', - groupsData: groups?.slice(0, 2), - ordersData: orders?.slice(0, 2), - deliveriesData: deliveries?.slice(0, 2) - }); - const getGroupStats = (groupId: number) => { - const groupOrders = Array.isArray(orders) ? orders.filter(order => { - const match = order.groupId === groupId; - if (match) console.log(`🎯 Order ${order.id} matches group ${groupId}`, order); - return match; - }) : []; - - const groupDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => { - const match = delivery.groupId === groupId; - if (match) console.log(`🎯 Delivery ${delivery.id} matches group ${groupId}`, delivery); - return match; - }) : []; - - console.log(`🏪 Group ${groupId} stats:`, { - groupOrders: groupOrders.length, - groupDeliveries: groupDeliveries.length, - delivered: groupDeliveries.filter(d => d.status === 'delivered').length, - ordersSample: groupOrders.slice(0, 2), - deliveriesSample: groupDeliveries.slice(0, 2), - allOrdersCount: orders?.length, - allDeliveriesCount: deliveries?.length - }); - + const stats = Array.isArray(groupStats) + ? groupStats.find(s => s.id === groupId) + : undefined; + return { - orders: groupOrders.length, - deliveries: groupDeliveries.length, - delivered: groupDeliveries.filter(d => d.status === 'delivered').length, + orders: stats?.orders ?? 0, + deliveries: stats?.deliveries ?? 0, + delivered: stats?.delivered ?? 0, }; }; diff --git a/client/src/pages/Suppliers.tsx b/client/src/pages/Suppliers.tsx index f5f114d..a36a96e 100644 --- a/client/src/pages/Suppliers.tsx +++ b/client/src/pages/Suppliers.tsx @@ -29,7 +29,7 @@ import { CheckCircle, Clock } from "lucide-react"; -import type { Supplier } from "@shared/schema"; +import type { EntityActivityStats, Supplier } from "@shared/schema"; export default function Suppliers() { const { user } = useAuthUnified(); @@ -56,12 +56,10 @@ export default function Suppliers() { queryKey: ['/api/suppliers'], }); - const { data: orders = [] } = useQuery({ - queryKey: ['/api/orders'], - }); - - const { data: deliveries = [] } = useQuery({ - queryKey: ['/api/deliveries'], + // Comptages agrégés en base : la page n'affiche que des totaux, inutile de + // rapatrier l'historique complet des commandes et des livraisons. + const { data: supplierStats = [] } = useQuery({ + queryKey: ['/api/stats/by-supplier'], }); const createMutation = useMutation({ @@ -280,14 +278,14 @@ export default function Suppliers() { ); const getSupplierStats = (supplierId: number) => { - const supplierOrders = orders.filter((order: any) => order.supplierId === supplierId); - const supplierDeliveries = deliveries.filter((delivery: any) => delivery.supplierId === supplierId); - const deliveredCount = supplierDeliveries.filter((delivery: any) => delivery.status === 'delivered').length; - + const stats = Array.isArray(supplierStats) + ? supplierStats.find(s => s.id === supplierId) + : undefined; + return { - orders: supplierOrders.length, - deliveries: supplierDeliveries.length, - delivered: deliveredCount, + orders: stats?.orders ?? 0, + deliveries: stats?.deliveries ?? 0, + delivered: stats?.delivered ?? 0, }; }; diff --git a/migrations/20260814_add_performance_indexes.sql b/migrations/20260814_add_performance_indexes.sql new file mode 100644 index 0000000..11f85aa --- /dev/null +++ b/migrations/20260814_add_performance_indexes.sql @@ -0,0 +1,109 @@ +-- Performance indexes +-- +-- Contexte : aucune table (hors sessions) ne portait d'index en dehors des clés +-- primaires et contraintes UNIQUE. PostgreSQL n'indexe pas automatiquement les +-- clés étrangères, donc tous les filtres et jointures sur group_id, supplier_id, +-- order_id et les colonnes de date faisaient un scan séquentiel complet. +-- +-- Ce fichier est exécuté par migrations/migrate.js, qui l'enveloppe dans une +-- transaction : CREATE INDEX CONCURRENTLY y est interdit. Les index sont donc +-- créés en mode bloquant (verrou en écriture sur la table, le temps de la +-- construction). Pour appliquer sans interruption sur une base en production +-- chargée, utiliser scripts/create-indexes-concurrently.js AVANT le déploiement : +-- les index existeront déjà et les IF NOT EXISTS ci-dessous seront sans effet. + +-- === user_groups === +-- Lu à chaque requête authentifiée via getUserWithGroups(). +CREATE INDEX IF NOT EXISTS idx_user_groups_user_id ON user_groups (user_id); +CREATE INDEX IF NOT EXISTS idx_user_groups_group_id ON user_groups (group_id); + +-- === orders === +CREATE INDEX IF NOT EXISTS idx_orders_group_id ON orders (group_id); +CREATE INDEX IF NOT EXISTS idx_orders_supplier_id ON orders (supplier_id); +CREATE INDEX IF NOT EXISTS idx_orders_created_by ON orders (created_by); +CREATE INDEX IF NOT EXISTS idx_orders_planned_date ON orders (planned_date); +-- Requête du calendrier : filtre par magasin + plage de dates. +CREATE INDEX IF NOT EXISTS idx_orders_group_planned_date ON orders (group_id, planned_date); +CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders (created_at DESC); + +-- === deliveries === +-- order_id est la clé de jointure du N+1 de getOrders() : le plus rentable. +CREATE INDEX IF NOT EXISTS idx_deliveries_order_id ON deliveries (order_id); +CREATE INDEX IF NOT EXISTS idx_deliveries_group_id ON deliveries (group_id); +CREATE INDEX IF NOT EXISTS idx_deliveries_supplier_id ON deliveries (supplier_id); +CREATE INDEX IF NOT EXISTS idx_deliveries_created_by ON deliveries (created_by); +CREATE INDEX IF NOT EXISTS idx_deliveries_scheduled_date ON deliveries (scheduled_date); +CREATE INDEX IF NOT EXISTS idx_deliveries_group_scheduled_date ON deliveries (group_id, scheduled_date); +CREATE INDEX IF NOT EXISTS idx_deliveries_created_at ON deliveries (created_at DESC); +-- Rapprochement BL / factures. +CREATE INDEX IF NOT EXISTS idx_deliveries_invoice_reference ON deliveries (invoice_reference) + WHERE invoice_reference IS NOT NULL; + +-- === reconciliation_comments === +CREATE INDEX IF NOT EXISTS idx_reconciliation_comments_delivery_id ON reconciliation_comments (delivery_id); +CREATE INDEX IF NOT EXISTS idx_reconciliation_comments_group_id ON reconciliation_comments (group_id); + +-- === contacts === +CREATE INDEX IF NOT EXISTS idx_contacts_group_id ON contacts (group_id); + +-- === customer_orders === +CREATE INDEX IF NOT EXISTS idx_customer_orders_group_id ON customer_orders (group_id); +CREATE INDEX IF NOT EXISTS idx_customer_orders_supplier_id ON customer_orders (supplier_id); +CREATE INDEX IF NOT EXISTS idx_customer_orders_created_at ON customer_orders (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_customer_orders_group_status ON customer_orders (group_id, status); + +-- === dlc_products === +CREATE INDEX IF NOT EXISTS idx_dlc_products_group_id ON dlc_products (group_id); +CREATE INDEX IF NOT EXISTS idx_dlc_products_supplier_id ON dlc_products (supplier_id); +CREATE INDEX IF NOT EXISTS idx_dlc_products_expiry_date ON dlc_products (expiry_date); +CREATE INDEX IF NOT EXISTS idx_dlc_products_group_status ON dlc_products (group_id, status); + +-- === tasks === +CREATE INDEX IF NOT EXISTS idx_tasks_group_id ON tasks (group_id); +CREATE INDEX IF NOT EXISTS idx_tasks_created_by ON tasks (created_by); +CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks (due_date); +CREATE INDEX IF NOT EXISTS idx_tasks_group_status ON tasks (group_id, status); + +-- === avoirs === +CREATE INDEX IF NOT EXISTS idx_avoirs_group_id ON avoirs (group_id); +CREATE INDEX IF NOT EXISTS idx_avoirs_supplier_id ON avoirs (supplier_id); +CREATE INDEX IF NOT EXISTS idx_avoirs_created_at ON avoirs (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_avoirs_group_status ON avoirs (group_id, status); + +-- === sav_tickets === +CREATE INDEX IF NOT EXISTS idx_sav_tickets_group_id ON sav_tickets (group_id); +CREATE INDEX IF NOT EXISTS idx_sav_tickets_supplier_id ON sav_tickets (supplier_id); +CREATE INDEX IF NOT EXISTS idx_sav_tickets_created_at ON sav_tickets (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_sav_tickets_group_status ON sav_tickets (group_id, status); + +-- === invoice_verification_cache === +-- expires_at sert la purge périodique (cleanExpiredCache). +CREATE INDEX IF NOT EXISTS idx_invoice_cache_expires_at ON invoice_verification_cache (expires_at); +CREATE INDEX IF NOT EXISTS idx_invoice_cache_group_id ON invoice_verification_cache (group_id); + +-- === publicity_participations === +-- La clé primaire (publicity_id, group_id) ne couvre pas les recherches par group_id seul. +CREATE INDEX IF NOT EXISTS idx_publicity_participations_group_id ON publicity_participations (group_id); + +-- === publicities === +CREATE INDEX IF NOT EXISTS idx_publicities_year ON publicities (year); + +-- === dashboard_messages === +CREATE INDEX IF NOT EXISTS idx_dashboard_messages_store_id ON dashboard_messages (store_id); +CREATE INDEX IF NOT EXISTS idx_dashboard_messages_created_at ON dashboard_messages (created_at DESC); + +-- === weather_data === +CREATE INDEX IF NOT EXISTS idx_weather_data_date_location ON weather_data (date, location); + +-- Rafraîchit les statistiques du planificateur pour qu'il utilise réellement +-- les nouveaux index sans attendre le prochain passage d'autovacuum. +ANALYZE user_groups; +ANALYZE orders; +ANALYZE deliveries; +ANALYZE reconciliation_comments; +ANALYZE customer_orders; +ANALYZE dlc_products; +ANALYZE tasks; +ANALYZE avoirs; +ANALYZE sav_tickets; +ANALYZE invoice_verification_cache; diff --git a/scripts/auto-migrate-production.sh b/scripts/auto-migrate-production.sh index 3904da9..8cc2049 100644 --- a/scripts/auto-migrate-production.sh +++ b/scripts/auto-migrate-production.sh @@ -111,4 +111,135 @@ else echo "ℹ️ [AUTO-MIGRATE] Table webhook_bap_config existe déjà - aucune action nécessaire" fi -echo "✅ [AUTO-MIGRATE] Migration terminée avec succès - webhook_bap_config incluse!" \ No newline at end of file +# Index de performance +# Aucune table ne portait d'index en dehors des clés primaires et contraintes +# UNIQUE. PostgreSQL n'indexe pas les clés étrangères automatiquement : tous les +# filtres sur group_id / supplier_id / order_id et les colonnes de date +# faisaient un scan séquentiel complet, d'où la dégradation progressive à mesure +# que les tables grossissent. +# +# CREATE INDEX CONCURRENTLY ne verrouille pas la table en écriture, mais ne peut +# pas s'exécuter dans une transaction : d'où l'appel psql direct sans BEGIN. +echo "🔄 [AUTO-MIGRATE] Vérification des index de performance..." + +if psql "$DATABASE_URL" -tAc "SELECT EXISTS (SELECT FROM pg_indexes WHERE schemaname='public' AND indexname='idx_deliveries_order_id');" | grep -q "f"; then + echo "🔧 [AUTO-MIGRATE] Création des index de performance (sans interruption de service)..." + + # Une création CONCURRENTLY interrompue laisse un index invalide, que + # CREATE INDEX IF NOT EXISTS considère ensuite comme déjà présent. On purge + # ces reliquats avant de (re)créer. + psql "$DATABASE_URL" << 'EOF' +DO $$ +DECLARE idx RECORD; +BEGIN + FOR idx IN + SELECT c.relname + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE NOT i.indisvalid AND n.nspname = 'public' AND c.relname LIKE 'idx_%' + LOOP + EXECUTE format('DROP INDEX IF EXISTS public.%I', idx.relname); + RAISE NOTICE 'Index invalide supprimé: %', idx.relname; + END LOOP; +END $$; +EOF + + psql "$DATABASE_URL" << 'EOF' +-- user_groups : lu à chaque requête authentifiée via getUserWithGroups() +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_groups_user_id ON user_groups (user_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_user_groups_group_id ON user_groups (group_id); + +-- orders +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_group_id ON orders (group_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_supplier_id ON orders (supplier_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_created_by ON orders (created_by); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_planned_date ON orders (planned_date); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_group_planned_date ON orders (group_id, planned_date); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_created_at ON orders (created_at DESC); + +-- deliveries : order_id est la clé de jointure la plus sollicitée +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_order_id ON deliveries (order_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_group_id ON deliveries (group_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_supplier_id ON deliveries (supplier_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_created_by ON deliveries (created_by); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_scheduled_date ON deliveries (scheduled_date); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_group_scheduled_date ON deliveries (group_id, scheduled_date); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_created_at ON deliveries (created_at DESC); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_deliveries_invoice_reference ON deliveries (invoice_reference) WHERE invoice_reference IS NOT NULL; + +-- reconciliation_comments +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_reconciliation_comments_delivery_id ON reconciliation_comments (delivery_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_reconciliation_comments_group_id ON reconciliation_comments (group_id); + +-- contacts +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_contacts_group_id ON contacts (group_id); + +-- customer_orders +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_customer_orders_group_id ON customer_orders (group_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_customer_orders_supplier_id ON customer_orders (supplier_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_customer_orders_created_at ON customer_orders (created_at DESC); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_customer_orders_group_status ON customer_orders (group_id, status); + +-- dlc_products +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_dlc_products_group_id ON dlc_products (group_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_dlc_products_supplier_id ON dlc_products (supplier_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_dlc_products_expiry_date ON dlc_products (expiry_date); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_dlc_products_group_status ON dlc_products (group_id, status); + +-- tasks +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_group_id ON tasks (group_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_created_by ON tasks (created_by); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_due_date ON tasks (due_date); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_group_status ON tasks (group_id, status); + +-- avoirs +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_avoirs_group_id ON avoirs (group_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_avoirs_supplier_id ON avoirs (supplier_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_avoirs_created_at ON avoirs (created_at DESC); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_avoirs_group_status ON avoirs (group_id, status); + +-- sav_tickets +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sav_tickets_group_id ON sav_tickets (group_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sav_tickets_supplier_id ON sav_tickets (supplier_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sav_tickets_created_at ON sav_tickets (created_at DESC); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sav_tickets_group_status ON sav_tickets (group_id, status); + +-- invoice_verification_cache : expires_at sert la purge périodique +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_invoice_cache_expires_at ON invoice_verification_cache (expires_at); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_invoice_cache_group_id ON invoice_verification_cache (group_id); + +-- publicity_participations : la PK (publicity_id, group_id) ne couvre pas group_id seul +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_publicity_participations_group_id ON publicity_participations (group_id); + +-- publicities +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_publicities_year ON publicities (year); + +-- dashboard_messages +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_dashboard_messages_store_id ON dashboard_messages (store_id); +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_dashboard_messages_created_at ON dashboard_messages (created_at DESC); + +-- weather_data +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_weather_data_date_location ON weather_data (date, location); +EOF + + # Rafraîchit les statistiques du planificateur pour qu'il utilise réellement + # les nouveaux index sans attendre le prochain autovacuum. + psql "$DATABASE_URL" << 'EOF' +ANALYZE user_groups; +ANALYZE orders; +ANALYZE deliveries; +ANALYZE reconciliation_comments; +ANALYZE customer_orders; +ANALYZE dlc_products; +ANALYZE tasks; +ANALYZE avoirs; +ANALYZE sav_tickets; +ANALYZE invoice_verification_cache; +EOF + echo "✅ [AUTO-MIGRATE] Index de performance créés avec succès" +else + echo "ℹ️ [AUTO-MIGRATE] Index de performance déjà présents - aucune action nécessaire" +fi + +echo "✅ [AUTO-MIGRATE] Migration terminée avec succès - index de performance inclus!" \ No newline at end of file diff --git a/server/index.ts b/server/index.ts index 5e16e80..6829e00 100644 --- a/server/index.ts +++ b/server/index.ts @@ -59,6 +59,10 @@ app.use((req, res, next) => { const server = await registerRoutes(app); +// Tâches de maintenance périodiques (purge du cache factures expiré) +const { startMaintenanceJobs } = await import('./maintenance.js'); +startMaintenanceJobs(); + app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { const status = err.status || err.statusCode || 500; const message = err.message || "Internal Server Error"; diff --git a/server/maintenance.ts b/server/maintenance.ts new file mode 100644 index 0000000..ecc0979 --- /dev/null +++ b/server/maintenance.ts @@ -0,0 +1,27 @@ +import { storage } from "./storage.js"; + +// Purge périodique du cache de vérification des factures. +// +// La table invoice_verification_cache porte une colonne expires_at et +// storage.clearExpiredCache() sait la purger, mais rien ne l'appelait : les +// entrées expirées s'accumulaient indéfiniment et alourdissaient chaque lecture +// du cache. On planifie donc la purge ici. + +const PURGE_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 heures +const STARTUP_DELAY_MS = 60 * 1000; // laisse le démarrage se terminer d'abord + +async function purgeExpiredInvoiceCache(): Promise { + try { + await storage.clearExpiredCache(); + console.log("🧹 [MAINTENANCE] Cache de vérification des factures purgé"); + } catch (error) { + // Une purge ratée ne doit jamais interrompre le service. + console.error("❌ [MAINTENANCE] Échec de la purge du cache factures:", error); + } +} + +export function startMaintenanceJobs(): void { + setTimeout(purgeExpiredInvoiceCache, STARTUP_DELAY_MS); + setInterval(purgeExpiredInvoiceCache, PURGE_INTERVAL_MS); + console.log("✅ [MAINTENANCE] Purge du cache factures planifiée (toutes les 6h)"); +} diff --git a/server/routes.ts b/server/routes.ts index dd77454..20eaeac 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1389,6 +1389,66 @@ export async function registerRoutes(app: Express): Promise { } }); + // Statistiques agrégées commandes/livraisons. + // + // Les pages Magasins et Fournisseurs n'affichent que des comptages. Elles + // chargeaient tout l'historique via /api/orders et /api/deliveries pour ne + // faire que des .filter().length côté navigateur : le comptage est fait en + // base et seules les valeurs agrégées transitent. + const resolveStatsGroupIds = (user: any, storeId?: string): number[] | undefined | null => { + const userGroupIds: number[] = user.userGroups?.map((ug: any) => ug.groupId) || []; + + if (user.role === 'admin') { + return storeId ? [parseInt(storeId)] : undefined; // undefined = tous les magasins + } + + if (storeId) { + const requestedStoreId = parseInt(storeId); + // null = accès refusé au magasin demandé + return userGroupIds.includes(requestedStoreId) ? [requestedStoreId] : null; + } + + return userGroupIds.length > 0 ? userGroupIds : null; + }; + + app.get('/api/stats/by-group', isAuthenticated, async (req: any, res) => { + try { + const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + const groupIds = resolveStatsGroupIds(user, req.query.storeId as string | undefined); + if (groupIds === null) { + return res.json([]); + } + + res.json(await storage.getOrderDeliveryStatsByGroup(groupIds)); + } catch (error) { + console.error("Error fetching group stats:", error); + res.status(500).json({ message: "Failed to fetch group stats" }); + } + }); + + app.get('/api/stats/by-supplier', isAuthenticated, async (req: any, res) => { + try { + const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + const groupIds = resolveStatsGroupIds(user, req.query.storeId as string | undefined); + if (groupIds === null) { + return res.json([]); + } + + res.json(await storage.getOrderDeliveryStatsBySupplier(groupIds)); + } catch (error) { + console.error("Error fetching supplier stats:", error); + res.status(500).json({ message: "Failed to fetch supplier stats" }); + } + }); + app.get('/api/orders/:id', isAuthenticated, async (req: any, res) => { try { const user = await storage.getUserWithGroups(req.user.claims ? req.user.claims.sub : req.user.id); @@ -1571,11 +1631,11 @@ export async function registerRoutes(app: Express): Promise { const fixedOrders = []; for (const order of orders) { - // Get deliveries for this order separately since OrderWithRelations doesn't include deliveries - const deliveries = await storage.getDeliveries(); - const orderDeliveries = deliveries.filter(d => d.orderId === order.id); + // getOrders() renvoie déjà les livraisons de chaque commande : les + // recharger ici relisait toute la table à chaque itération. + const orderDeliveries = order.deliveries ?? []; - if (orderDeliveries && orderDeliveries.length > 0) { + if (orderDeliveries.length > 0) { const hasDeliveredDeliveries = orderDeliveries.some((d: any) => d.status === 'delivered'); if (hasDeliveredDeliveries && order.status !== 'delivered') { diff --git a/server/storage.ts b/server/storage.ts index d970654..9ce7f28 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -32,6 +32,7 @@ import { type InsertUserGroup, type OrderWithRelations, type DeliveryWithRelations, + type EntityActivityStats, type UserWithGroups, type Publicity, type InsertPublicity, @@ -184,6 +185,10 @@ export interface IStorage { createInvoiceVerificationCache(cache: InsertInvoiceVerificationCache): Promise; clearExpiredCache(): Promise; + // Statistiques agrégées (comptages calculés en base, sans rapatrier les lignes) + getOrderDeliveryStatsByGroup(groupIds?: number[]): Promise; + getOrderDeliveryStatsBySupplier(groupIds?: number[]): Promise; + // Customer Order operations getCustomerOrders(groupIds?: number[]): Promise; getCustomerOrder(id: number): Promise; @@ -489,6 +494,168 @@ export class DatabaseStorage implements IStorage { await db.delete(contacts).where(eq(contacts.id, id)); } + // Chargement groupé des relations. + // + // Les listes de commandes et de livraisons chargeaient auparavant leurs + // relations avec une requête par ligne (N+1). Sur un historique d'un an cela + // représentait plusieurs milliers d'allers-retours par appel d'API. Ces + // helpers récupèrent tout en une seule requête et regroupent côté Node. + + private async loadDeliveriesByOrderIds(orderIds: number[]): Promise> { + const byOrderId = new Map(); + if (orderIds.length === 0) return byOrderId; + + const rows = await db + .select({ + id: deliveries.id, + orderId: deliveries.orderId, + supplierId: deliveries.supplierId, + groupId: deliveries.groupId, + scheduledDate: deliveries.scheduledDate, + deliveredDate: deliveries.deliveredDate, + status: deliveries.status, + quantity: deliveries.quantity, + unit: deliveries.unit, + blNumber: deliveries.blNumber, + blAmount: deliveries.blAmount, + invoiceReference: deliveries.invoiceReference, + invoiceAmount: deliveries.invoiceAmount, + invoiceAmountTTC: deliveries.invoiceAmountTTC, + dueDate: deliveries.dueDate, + reconciled: deliveries.reconciled, + validatedAt: deliveries.validatedAt, + controlValidated: deliveries.controlValidated, + controlValidatedBy: deliveries.controlValidatedBy, + controlValidatedAt: deliveries.controlValidatedAt, + notes: deliveries.notes, + createdBy: deliveries.createdBy, + createdAt: deliveries.createdAt, + updatedAt: deliveries.updatedAt, + supplier: suppliers, + group: groups, + creator: { + id: users.id, + firstName: users.firstName, + lastName: users.lastName, + username: users.username, + email: users.email + } + }) + .from(deliveries) + .leftJoin(suppliers, eq(deliveries.supplierId, suppliers.id)) + .leftJoin(groups, eq(deliveries.groupId, groups.id)) + .leftJoin(users, eq(deliveries.createdBy, users.id)) + .where(inArray(deliveries.orderId, orderIds)); + + for (const row of rows) { + if (row.orderId === null) continue; + const existing = byOrderId.get(row.orderId); + if (existing) { + existing.push(row); + } else { + byOrderId.set(row.orderId, [row]); + } + } + + return byOrderId; + } + + private async loadOrdersByIds(orderIds: number[]): Promise> { + const byId = new Map(); + if (orderIds.length === 0) return byId; + + const rows = await db + .select({ + id: orders.id, + supplierId: orders.supplierId, + groupId: orders.groupId, + plannedDate: orders.plannedDate, + status: orders.status, + quantity: orders.quantity, + unit: orders.unit, + notes: orders.notes, + createdBy: orders.createdBy, + createdAt: orders.createdAt, + updatedAt: orders.updatedAt, + supplier: suppliers, + group: groups, + creator: { + id: users.id, + firstName: users.firstName, + lastName: users.lastName, + username: users.username, + email: users.email + } + }) + .from(orders) + .leftJoin(suppliers, eq(orders.supplierId, suppliers.id)) + .leftJoin(groups, eq(orders.groupId, groups.id)) + .leftJoin(users, eq(orders.createdBy, users.id)) + .where(inArray(orders.id, orderIds)); + + for (const row of rows) { + byId.set(row.id, row); + } + + return byId; + } + + private async loadReconciliationCommentCounts(deliveryIds: number[]): Promise> { + const counts = new Map(); + if (deliveryIds.length === 0) return counts; + + const rows = await db + .select({ + deliveryId: reconciliationComments.deliveryId, + count: sql`count(*)` + }) + .from(reconciliationComments) + .where(inArray(reconciliationComments.deliveryId, deliveryIds)) + .groupBy(reconciliationComments.deliveryId); + + for (const row of rows) { + counts.set(row.deliveryId, Number(row.count)); + } + + return counts; + } + + // Associe à chaque livraison sa commande et son nombre de commentaires, en + // deux requêtes au total quel que soit le nombre de livraisons. + private async attachOrdersAndCommentCounts(baseDeliveries: any[]): Promise { + const orderIds = Array.from( + new Set(baseDeliveries.map((d) => d.orderId).filter((id): id is number => id !== null && id !== undefined)) + ); + const deliveryIds = baseDeliveries.map((d) => d.id); + + const [ordersById, commentCounts] = await Promise.all([ + this.loadOrdersByIds(orderIds), + this.loadReconciliationCommentCounts(deliveryIds) + ]); + + return baseDeliveries.map((delivery) => { + let associatedOrder = undefined; + + if (delivery.orderId) { + const orderData = ordersById.get(delivery.orderId); + if (orderData) { + // Une livraison ne doit jamais exposer une commande d'un autre magasin. + if (orderData.groupId !== delivery.groupId) { + console.error(`❌ PRODUCTION: Delivery #${delivery.id} (store ${delivery.groupId}) linked to order #${delivery.orderId} (store ${orderData.groupId}) - STORE MISMATCH DETECTED!`); + } else { + associatedOrder = orderData; + } + } + } + + return { + ...delivery, + order: associatedOrder, + reconciliationCommentsCount: commentCounts.get(delivery.id) ?? 0 + }; + }); + } + // Order operations async getOrders(groupIds?: number[]): Promise { let query = db @@ -525,52 +692,13 @@ export class DatabaseStorage implements IStorage { const baseOrders = await query.orderBy(desc(orders.createdAt)); - // Pour chaque commande, récupérer ses livraisons associées (PRODUCTION RELATIONS) - const ordersWithDeliveries = await Promise.all( - baseOrders.map(async (order) => { - const associatedDeliveries = await db - .select({ - id: deliveries.id, - orderId: deliveries.orderId, - supplierId: deliveries.supplierId, - groupId: deliveries.groupId, - scheduledDate: deliveries.scheduledDate, - deliveredDate: deliveries.deliveredDate, - status: deliveries.status, - quantity: deliveries.quantity, - unit: deliveries.unit, - blNumber: deliveries.blNumber, - blAmount: deliveries.blAmount, - invoiceReference: deliveries.invoiceReference, - invoiceAmount: deliveries.invoiceAmount, - reconciled: deliveries.reconciled, - validatedAt: deliveries.validatedAt, - notes: deliveries.notes, - createdBy: deliveries.createdBy, - createdAt: deliveries.createdAt, - updatedAt: deliveries.updatedAt, - supplier: suppliers, - group: groups, - creator: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email - } - }) - .from(deliveries) - .leftJoin(suppliers, eq(deliveries.supplierId, suppliers.id)) - .leftJoin(groups, eq(deliveries.groupId, groups.id)) - .leftJoin(users, eq(deliveries.createdBy, users.id)) - .where(eq(deliveries.orderId, order.id)); + // Livraisons associées chargées en une seule requête pour toutes les commandes. + const deliveriesByOrderId = await this.loadDeliveriesByOrderIds(baseOrders.map((o: any) => o.id)); - return { - ...order, - deliveries: associatedDeliveries - }; - }) - ); + const ordersWithDeliveries = baseOrders.map((order: any) => ({ + ...order, + deliveries: deliveriesByOrderId.get(order.id) ?? [] + })); console.log(`🔗 PRODUCTION: getOrders() récupéré ${ordersWithDeliveries.length} commandes avec relations`); return ordersWithDeliveries as OrderWithRelations[]; @@ -623,52 +751,13 @@ export class DatabaseStorage implements IStorage { const baseOrders = await query.orderBy(desc(orders.plannedDate)); - // Pour chaque commande, récupérer ses livraisons associées (PRODUCTION RELATIONS) - const ordersWithDeliveries = await Promise.all( - baseOrders.map(async (order) => { - const associatedDeliveries = await db - .select({ - id: deliveries.id, - orderId: deliveries.orderId, - supplierId: deliveries.supplierId, - groupId: deliveries.groupId, - scheduledDate: deliveries.scheduledDate, - deliveredDate: deliveries.deliveredDate, - status: deliveries.status, - quantity: deliveries.quantity, - unit: deliveries.unit, - blNumber: deliveries.blNumber, - blAmount: deliveries.blAmount, - invoiceReference: deliveries.invoiceReference, - invoiceAmount: deliveries.invoiceAmount, - reconciled: deliveries.reconciled, - validatedAt: deliveries.validatedAt, - notes: deliveries.notes, - createdBy: deliveries.createdBy, - createdAt: deliveries.createdAt, - updatedAt: deliveries.updatedAt, - supplier: suppliers, - group: groups, - creator: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email - } - }) - .from(deliveries) - .leftJoin(suppliers, eq(deliveries.supplierId, suppliers.id)) - .leftJoin(groups, eq(deliveries.groupId, groups.id)) - .leftJoin(users, eq(deliveries.createdBy, users.id)) - .where(eq(deliveries.orderId, order.id)); + // Livraisons associées chargées en une seule requête pour toutes les commandes. + const deliveriesByOrderId = await this.loadDeliveriesByOrderIds(baseOrders.map((o: any) => o.id)); - return { - ...order, - deliveries: associatedDeliveries - }; - }) - ); + const ordersWithDeliveries = baseOrders.map((order: any) => ({ + ...order, + deliveries: deliveriesByOrderId.get(order.id) ?? [] + })); return ordersWithDeliveries as OrderWithRelations[]; } @@ -823,76 +912,7 @@ export class DatabaseStorage implements IStorage { const baseDeliveries = await query.orderBy(desc(deliveries.createdAt)); - // Pour chaque livraison, récupérer sa commande associée si elle existe (PRODUCTION RELATIONS) - const deliveriesWithOrders = await Promise.all( - baseDeliveries.map(async (delivery) => { - let associatedOrder = undefined; - - if (delivery.orderId) { - try { - const [orderData] = await db - .select({ - id: orders.id, - supplierId: orders.supplierId, - groupId: orders.groupId, - plannedDate: orders.plannedDate, - status: orders.status, - quantity: orders.quantity, - unit: orders.unit, - notes: orders.notes, - createdBy: orders.createdBy, - createdAt: orders.createdAt, - updatedAt: orders.updatedAt, - supplier: suppliers, - group: groups, - creator: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email - } - }) - .from(orders) - .leftJoin(suppliers, eq(orders.supplierId, suppliers.id)) - .leftJoin(groups, eq(orders.groupId, groups.id)) - .leftJoin(users, eq(orders.createdBy, users.id)) - .where(eq(orders.id, delivery.orderId)); - - if (orderData) { - // CRITICAL FIX: Vérifier que la commande appartient au même magasin que la livraison - if (orderData.groupId !== delivery.groupId) { - console.error(`❌ PRODUCTION: Delivery #${delivery.id} (store ${delivery.groupId}) linked to order #${delivery.orderId} (store ${orderData.groupId}) - STORE MISMATCH DETECTED!`); - // Ne pas inclure la commande si elle n'appartient pas au bon magasin - } else { - associatedOrder = orderData; - } - } - } catch (error) { - console.error(`❌ PRODUCTION: Failed to retrieve associated order #${delivery.orderId} for delivery #${delivery.id}:`, error); - } - } - - // Compter les commentaires de rapprochement pour cette livraison - let commentsCount = 0; - try { - const [countResult] = await db - .select({ count: sql`count(*)` }) - .from(reconciliationComments) - .where(eq(reconciliationComments.deliveryId, delivery.id)); - - commentsCount = Number(countResult?.count || 0); - } catch (error) { - console.error(`Failed to count reconciliation comments for delivery #${delivery.id}:`, error); - } - - return { - ...delivery, - order: associatedOrder, - reconciliationCommentsCount: commentsCount - }; - }) - ); + const deliveriesWithOrders = await this.attachOrdersAndCommentCounts(baseDeliveries); console.log(`🔗 PRODUCTION: getDeliveries() récupéré ${deliveriesWithOrders.length} livraisons avec relations`); return deliveriesWithOrders as DeliveryWithRelations[]; @@ -958,76 +978,7 @@ export class DatabaseStorage implements IStorage { const baseDeliveries = await query.orderBy(desc(deliveries.scheduledDate)); - // Pour chaque livraison, récupérer sa commande associée si elle existe (PRODUCTION RELATIONS) - const deliveriesWithOrders = await Promise.all( - baseDeliveries.map(async (delivery) => { - let associatedOrder = undefined; - - if (delivery.orderId) { - try { - const [orderData] = await db - .select({ - id: orders.id, - supplierId: orders.supplierId, - groupId: orders.groupId, - plannedDate: orders.plannedDate, - status: orders.status, - quantity: orders.quantity, - unit: orders.unit, - notes: orders.notes, - createdBy: orders.createdBy, - createdAt: orders.createdAt, - updatedAt: orders.updatedAt, - supplier: suppliers, - group: groups, - creator: { - id: users.id, - firstName: users.firstName, - lastName: users.lastName, - username: users.username, - email: users.email - } - }) - .from(orders) - .leftJoin(suppliers, eq(orders.supplierId, suppliers.id)) - .leftJoin(groups, eq(orders.groupId, groups.id)) - .leftJoin(users, eq(orders.createdBy, users.id)) - .where(eq(orders.id, delivery.orderId)); - - if (orderData) { - // CRITICAL FIX: Vérifier que la commande appartient au même magasin que la livraison - if (orderData.groupId !== delivery.groupId) { - console.error(`❌ PRODUCTION: Delivery #${delivery.id} (store ${delivery.groupId}) linked to order #${delivery.orderId} (store ${orderData.groupId}) - STORE MISMATCH DETECTED!`); - // Ne pas inclure la commande si elle n'appartient pas au bon magasin - } else { - associatedOrder = orderData; - } - } - } catch (error) { - console.error(`❌ PRODUCTION: Failed to retrieve associated order #${delivery.orderId} for delivery #${delivery.id}:`, error); - } - } - - // Compter les commentaires de rapprochement pour cette livraison - let commentsCount = 0; - try { - const [countResult] = await db - .select({ count: sql`count(*)` }) - .from(reconciliationComments) - .where(eq(reconciliationComments.deliveryId, delivery.id)); - - commentsCount = Number(countResult?.count || 0); - } catch (error) { - console.error(`Failed to count reconciliation comments for delivery #${delivery.id}:`, error); - } - - return { - ...delivery, - order: associatedOrder, - reconciliationCommentsCount: commentsCount - }; - }) - ); + const deliveriesWithOrders = await this.attachOrdersAndCommentCounts(baseDeliveries); return deliveriesWithOrders as DeliveryWithRelations[]; } @@ -1789,6 +1740,93 @@ export class DatabaseStorage implements IStorage { ); } + // Statistiques agrégées. + // + // Les pages Magasins et Fournisseurs n'affichent que des comptages. Elles + // téléchargeaient auparavant l'intégralité des commandes et des livraisons, + // relations imbriquées comprises, pour ne faire que des .filter().length côté + // navigateur. Ces deux méthodes font le comptage en base. + + private mergeActivityStats( + orderRows: { id: number | null; count: unknown }[], + deliveryRows: { id: number | null; count: unknown; delivered: unknown }[] + ): EntityActivityStats[] { + const byId = new Map(); + + const entryFor = (id: number): EntityActivityStats => { + let entry = byId.get(id); + if (!entry) { + entry = { id, orders: 0, deliveries: 0, delivered: 0 }; + byId.set(id, entry); + } + return entry; + }; + + for (const row of orderRows) { + if (row.id === null) continue; + entryFor(row.id).orders = Number(row.count) || 0; + } + + for (const row of deliveryRows) { + if (row.id === null) continue; + const entry = entryFor(row.id); + entry.deliveries = Number(row.count) || 0; + entry.delivered = Number(row.delivered) || 0; + } + + return Array.from(byId.values()); + } + + async getOrderDeliveryStatsByGroup(groupIds?: number[]): Promise { + const scoped = groupIds && groupIds.length > 0; + + const orderQuery = db + .select({ id: orders.groupId, count: sql`count(*)` }) + .from(orders) + .groupBy(orders.groupId); + + const deliveryQuery = db + .select({ + id: deliveries.groupId, + count: sql`count(*)`, + delivered: sql`count(*) filter (where ${deliveries.status} = 'delivered')` + }) + .from(deliveries) + .groupBy(deliveries.groupId); + + const [orderRows, deliveryRows] = await Promise.all([ + scoped ? orderQuery.where(inArray(orders.groupId, groupIds!)) : orderQuery, + scoped ? deliveryQuery.where(inArray(deliveries.groupId, groupIds!)) : deliveryQuery + ]); + + return this.mergeActivityStats(orderRows, deliveryRows); + } + + async getOrderDeliveryStatsBySupplier(groupIds?: number[]): Promise { + const scoped = groupIds && groupIds.length > 0; + + const orderQuery = db + .select({ id: orders.supplierId, count: sql`count(*)` }) + .from(orders) + .groupBy(orders.supplierId); + + const deliveryQuery = db + .select({ + id: deliveries.supplierId, + count: sql`count(*)`, + delivered: sql`count(*) filter (where ${deliveries.status} = 'delivered')` + }) + .from(deliveries) + .groupBy(deliveries.supplierId); + + const [orderRows, deliveryRows] = await Promise.all([ + scoped ? orderQuery.where(inArray(orders.groupId, groupIds!)) : orderQuery, + scoped ? deliveryQuery.where(inArray(deliveries.groupId, groupIds!)) : deliveryQuery + ]); + + return this.mergeActivityStats(orderRows, deliveryRows); + } + // Real implementations for production operations async getCustomerOrders(groupIds?: number[]): Promise { let query = db @@ -3762,6 +3800,47 @@ export class MemStorage implements IStorage { // No-op en développement } + private activityStatsBy( + key: 'groupId' | 'supplierId', + groupIds?: number[] + ): EntityActivityStats[] { + const byId = new Map(); + + const entryFor = (id: number): EntityActivityStats => { + let entry = byId.get(id); + if (!entry) { + entry = { id, orders: 0, deliveries: 0, delivered: 0 }; + byId.set(id, entry); + } + return entry; + }; + + const inScope = (groupId: number) => + !groupIds || groupIds.length === 0 || groupIds.includes(groupId); + + for (const order of this.orders.values()) { + if (!inScope(order.groupId)) continue; + entryFor(order[key]).orders += 1; + } + + for (const delivery of this.deliveries.values()) { + if (!inScope(delivery.groupId)) continue; + const entry = entryFor(delivery[key]); + entry.deliveries += 1; + if (delivery.status === 'delivered') entry.delivered += 1; + } + + return Array.from(byId.values()); + } + + async getOrderDeliveryStatsByGroup(groupIds?: number[]): Promise { + return this.activityStatsBy('groupId', groupIds); + } + + async getOrderDeliveryStatsBySupplier(groupIds?: number[]): Promise { + return this.activityStatsBy('supplierId', groupIds); + } + // MemStorage implementations with actual data handling async getOrders(groupIds?: number[]): Promise { let orders = Array.from(this.orders.values()); diff --git a/shared/schema.ts b/shared/schema.ts index ae7e854..f5564d9 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -770,6 +770,15 @@ export type OrderWithRelations = Order & { deliveries?: DeliveryWithRelations[]; }; +// Comptages commandes/livraisons par magasin ou par fournisseur, agrégés en base. +// Évite de rapatrier tout l'historique côté client pour n'en compter que les lignes. +export type EntityActivityStats = { + id: number; + orders: number; + deliveries: number; + delivered: number; +}; + export type DeliveryWithRelations = Delivery & { supplier: Supplier; group: Group;