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;