import { users, groups, suppliers, orders, deliveries, userGroups, publicities, publicityParticipations, customerOrders, dlcProducts, tasks, dashboardMessages, announcements, nocodbConfig, supplierMailLogs, invoiceVerificationCache, reconciliationComments, savTickets, weatherData, weatherSettings, type User, type UpsertUser, type Group, type InsertGroup, type Supplier, type InsertSupplier, type Order, type InsertOrder, type Delivery, type InsertDelivery, type UserGroup, type InsertUserGroup, type OrderWithRelations, type DeliveryWithRelations, type EntityActivityStats, type UserWithGroups, type Publicity, type InsertPublicity, type PublicityParticipation, type InsertPublicityParticipation, type PublicityWithRelations, type CustomerOrder, type InsertCustomerOrder, type CustomerOrderWithRelations, type DlcProduct, type InsertDlcProduct, type DlcProductFrontend, type InsertDlcProductFrontend, type DlcProductWithRelations, type Task, type InsertTask, type TaskWithRelations, type Announcement, type InsertAnnouncement, type AnnouncementWithRelations, type ReconciliationComment, type InsertReconciliationComment, type ReconciliationCommentWithRelations, type DashboardMessage, type NocodbConfig, type InsertNocodbConfig, type SupplierMailLog, type InsertSupplierMailLog, type InvoiceVerificationCache, type InsertInvoiceVerificationCache, type SavTicket, type InsertSavTicket, type SavTicketHistory, type InsertSavTicketHistory, type SavTicketWithRelations, type SavTicketHistoryWithCreator, type WeatherData, type InsertWeatherData, type WeatherSettings, type InsertWeatherSettings, webhookBapConfig, type WebhookBapConfig, type InsertWebhookBapConfig, utilities, type Utilities, type InsertUtilities, avoirs, type Avoir, type InsertAvoir, type AvoirWithRelations, contacts, type Contact, type InsertContact, } from "@shared/schema"; import { encryptSecret, decryptSecret } from "./crypto"; import { db } from "./db"; import { eq, and, inArray, desc, sql, gte, lte, lt, gt, or, isNull, isNotNull, asc, ne, getTableColumns } from "drizzle-orm"; import { getAnnouncementStorage } from "./announcementStorage"; // Projections des relations jointes dans les listes. // // La ligne groups complète contient le logo (data URI de plusieurs dizaines de // Ko) et la configuration SMTP : la joindre telle quelle la recopiait dans // chaque ligne de chaque liste. Le client ne lit que ces champs. Le // rapprochement et les avoirs ont en plus besoin de la config NocoDB et du // webhook. La fiche complète reste disponible via getGroup/getGroups. // L'id doit rester en tête : drizzle renvoie null pour la relation quand la // première colonne d'une jointure externe est nulle. const groupSummaryColumns = { id: groups.id, name: groups.name, color: groups.color, }; const groupReconciliationColumns = { ...groupSummaryColumns, nocodbConfigId: groups.nocodbConfigId, nocodbTableName: groups.nocodbTableName, webhookUrl: groups.webhookUrl, }; // Créateur ou auteur joint à une ligne, sans le hash du mot de passe const userSummaryColumns = { id: users.id, firstName: users.firstName, lastName: users.lastName, username: users.username, email: users.email, }; // Toutes les colonnes de users sauf le hash du mot de passe const { password: _password, ...publicUserColumns } = getTableColumns(users); // Magasin joint aux affectations d'un utilisateur (forme de UserWithGroups). // Les dates restent la valeur brute du driver, comme avec l'ancienne requête SQL // brute, pour ne pas changer ce que reçoit le client. const userGroupColumns = { id: groups.id, name: groups.name, color: groups.color, createdAt: sql`${groups.createdAt}`, updatedAt: sql`${groups.updatedAt}`, }; export interface IStorage { // User operations getUser(id: string): Promise; getUserByEmail(email: string): Promise; getUserByUsername(username: string): Promise; upsertUser(user: UpsertUser): Promise; getUserWithGroups(id: string): Promise; // Tous les utilisateurs avec leurs magasins, sans le champ password getUsersWithGroups(): Promise; getUsers(): Promise; createUser(user: UpsertUser): Promise; updateUser(id: string, user: Partial): Promise; deleteUser(id: string): Promise; // Group operations getGroups(): Promise; getGroup(id: number): Promise; createGroup(group: InsertGroup): Promise; updateGroup(id: number, group: Partial): Promise; deleteGroup(id: number): Promise; // Supplier operations getSuppliers(): Promise; createSupplier(supplier: InsertSupplier): Promise; updateSupplier(id: number, supplier: Partial): Promise; deleteSupplier(id: number): Promise; // Contact operations getContacts(groupIds?: number[]): Promise; createContact(contact: InsertContact): Promise; updateContact(id: number, contact: Partial): Promise; deleteContact(id: number): Promise; // Order operations getOrders(groupIds?: number[]): Promise; getOrdersByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise; getOrder(id: number): Promise; createOrder(order: InsertOrder): Promise; updateOrder(id: number, order: Partial): Promise; deleteOrder(id: number): Promise; // Delivery operations getDeliveries(groupIds?: number[], options?: { status?: string }): Promise; getDeliveriesByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise; getDelivery(id: number): Promise; createDelivery(delivery: InsertDelivery): Promise; updateDelivery(id: number, delivery: Partial): Promise; deleteDelivery(id: number): Promise; validateDelivery(id: number, blData?: { blNumber: string; blAmount: number }): Promise; markDeliveryControlValidated(id: number, userId: string): Promise; // User-Group operations getUserGroups(userId: string): Promise; assignUserToGroup(userGroup: InsertUserGroup): Promise; removeUserFromGroup(userId: string, groupId: number): Promise; removeUserFromAllGroups(userId: string): Promise; // Statistics getMonthlyStats(year: number, month: number, groupIds?: number[]): Promise<{ ordersCount: number; deliveriesCount: number; pendingOrdersCount: number; averageDeliveryTime: number; totalPalettes: number; totalPackages: number; }>; getYearlyStats(year: number, groupIds?: number[]): Promise<{ ordersCount: number; deliveriesCount: number; pendingOrdersCount: number; averageDeliveryTime: number; totalPalettes: number; totalPackages: number; }>; // Publicity operations getPublicities(year?: number, groupIds?: number[]): Promise; getPublicity(id: number): Promise; createPublicity(publicity: InsertPublicity): Promise; updatePublicity(id: number, publicity: Partial): Promise; deletePublicity(id: number): Promise; // Publicity participation operations getPublicityParticipations(publicityId: number): Promise; setPublicityParticipations(publicityId: number, groupIds: number[]): Promise; // NocoDB Configuration operations getNocodbConfigs(): Promise; getNocodbConfig(id: number): Promise; getActiveNocodbConfig(): Promise; createNocodbConfig(config: InsertNocodbConfig): Promise; updateNocodbConfig(id: number, config: Partial): Promise; deleteNocodbConfig(id: number): Promise; // Invoice Verification Cache operations getInvoiceVerificationCache(cacheKey: string): Promise; saveInvoiceVerificationCache(cache: InsertInvoiceVerificationCache): Promise; 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; createCustomerOrder(customerOrder: InsertCustomerOrder): Promise; updateCustomerOrder(id: number, customerOrder: Partial): Promise; deleteCustomerOrder(id: number): Promise; // Client call tracking getPendingClientCalls(groupIds?: number[]): Promise; markClientCalled(customerOrderId: number, calledBy: string, comment?: string): Promise; // DLC Product operations getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise; getDlcProduct(id: number): Promise; createDlcProduct(dlcProduct: InsertDlcProduct): Promise; updateDlcProduct(id: number, dlcProduct: Partial): Promise; deleteDlcProduct(id: number): Promise; validateDlcProduct(id: number, validatedBy: string): Promise; markDlcProductStockEpuise(id: number, markedBy: string): Promise; restoreDlcProductStock(id: number): Promise; markDlcProductAsProcessed(id: number, processedBy: string): Promise; unmarkDlcProductAsProcessed(id: number): Promise; getDlcStats(groupIds?: number[]): Promise<{ active: number; expiringSoon: number; expired: number; }>; // Task operations getTasks(groupIds?: number[], userRole?: string): Promise; getTask(id: number): Promise; createTask(task: InsertTask): Promise; updateTask(id: number, task: Partial): Promise; deleteTask(id: number): Promise; completeTask(id: number, completedBy?: string): Promise; // Announcement operations createAnnouncement(announcement: InsertAnnouncement): Promise; getAnnouncements(groupIds?: number[]): Promise; getAnnouncement(id: number): Promise; updateAnnouncement(id: number, announcement: Partial): Promise; deleteAnnouncement(id: number): Promise; // Avoir operations getAvoirs(groupIds?: number[]): Promise; getAvoir(id: number): Promise; createAvoir(avoir: InsertAvoir): Promise; updateAvoir(id: number, avoir: Partial): Promise; deleteAvoir(id: number): Promise; updateAvoirWebhookStatus(id: number, webhookSent: boolean): Promise; updateAvoirNocodbVerification(id: number, verified: boolean): Promise; // SAV operations getSavTickets(filters?: { groupIds?: number[]; status?: string; supplierId?: number; priority?: string; startDate?: string; endDate?: string; }): Promise; getSavTicket(id: number): Promise; createSavTicket(ticket: InsertSavTicket): Promise; updateSavTicket(id: number, ticket: Partial): Promise; deleteSavTicket(id: number): Promise; getSavTicketHistory(ticketId: number): Promise; addSavTicketHistory(history: InsertSavTicketHistory): Promise; getSavTicketStats(groupIds?: number[]): Promise<{ totalTickets: number; newTickets: number; inProgressTickets: number; resolvedTickets: number; criticalTickets: number; }>; // Weather operations getWeatherSettings(): Promise; createWeatherSettings(settings: InsertWeatherSettings): Promise; updateWeatherSettings(id: number, settings: Partial): Promise; getWeatherData(date: string, isCurrentYear: boolean): Promise; createWeatherData(data: InsertWeatherData): Promise; updateWeatherData(id: number, data: Partial): Promise; deleteOldWeatherData(daysToKeep: number): Promise; getNearestWeatherData(date: string, isCurrentYear: boolean, maxDistanceDays: number): Promise; clearWeatherCache(): Promise; // Historique des mails fournisseurs (rapprochement) createSupplierMailLog(log: InsertSupplierMailLog): Promise; getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise; // Webhook BAP Configuration getWebhookBapConfig(): Promise; createWebhookBapConfig(config: InsertWebhookBapConfig): Promise; updateWebhookBapConfig(id: number, config: Partial): Promise; // Utilities operations getUtilities(): Promise; createUtilities(utilities: InsertUtilities): Promise; updateUtilities(id: number, utilities: Partial): Promise; // Reconciliation Comments operations getReconciliationComments(deliveryId: number): Promise; getReconciliationCommentById(id: number): Promise; createReconciliationComment(comment: InsertReconciliationComment): Promise; updateReconciliationComment(id: number, comment: Partial): Promise; deleteReconciliationComment(id: number): Promise; // Analytics operations getAnalyticsSummary(filters: { startDate?: Date; endDate?: Date; supplierIds?: number[]; groupIds?: number[]; status?: string[]; }): Promise<{ totalOrders: number; totalDeliveries: number; reconciliationRate: number; totalAmount: number; avgDeliveryDelay: number; topSuppliers: Array<{ id: number; name: string; count: number; amount: number }>; topStores: Array<{ id: number; name: string; orders: number; deliveries: number }>; }>; getAnalyticsTimeseries(filters: { startDate?: Date; endDate?: Date; supplierIds?: number[]; groupIds?: number[]; granularity?: 'day' | 'week' | 'month'; }): Promise>; getAnalyticsBySupplier(filters: { startDate?: Date; endDate?: Date; groupIds?: number[]; }): Promise>; getAnalyticsByStore(filters: { startDate?: Date; endDate?: Date; supplierIds?: number[]; }): Promise>; } export class DatabaseStorage implements IStorage { // User operations async getUser(id: string): Promise { const [user] = await db.select().from(users).where(eq(users.id, id)); return user; } async getUserByEmail(email: string): Promise { const [user] = await db.select().from(users).where(eq(users.email, email)); return user; } async getUserByUsername(username: string): Promise { const [user] = await db.select().from(users).where(eq(users.username, username)); return user; } async upsertUser(userData: UpsertUser): Promise { const [user] = await db .insert(users) .values(userData) .onConflictDoUpdate({ target: users.id, set: { ...userData, updatedAt: new Date(), }, }) .returning(); return user; } async getUserWithGroups(id: string): Promise { // Appelée à chaque requête authentifiée (deserializeUser) : l'utilisateur // et ses magasins sont lus en une seule requête au lieu de deux successives try { const rows = await db .select({ user: users, groupId: userGroups.groupId, group: userGroupColumns, }) .from(users) .leftJoin(userGroups, eq(userGroups.userId, users.id)) .leftJoin(groups, eq(userGroups.groupId, groups.id)) .where(eq(users.id, id)); if (rows.length === 0) return undefined; // Les affectations vers un magasin inexistant sont ignorées, comme // avec la jointure interne d'origine const userGroupsList = rows .filter((row: any) => row.group !== null) .map((row: any) => ({ userId: id, groupId: row.groupId, group: row.group, })); return { ...rows[0].user, userGroups: userGroupsList }; } catch (error) { console.error('Error fetching user with groups:', error); const user = await this.getUser(id); return user ? { ...user, userGroups: [] } : undefined; } } async getUsersWithGroups(): Promise { // Deux requêtes en parallèle pour tous les utilisateurs, au lieu d'un // getUserWithGroups par utilisateur const [allUsers, memberships] = await Promise.all([ db.select(publicUserColumns).from(users), (async () => { try { return await db .select({ userId: userGroups.userId, groupId: userGroups.groupId, group: userGroupColumns, }) .from(userGroups) .innerJoin(groups, eq(userGroups.groupId, groups.id)); } catch (error) { console.error('Error fetching user groups:', error); return []; } })(), ]); const groupsByUserId = new Map(); for (const membership of memberships) { const list = groupsByUserId.get(membership.userId); if (list) { list.push(membership); } else { groupsByUserId.set(membership.userId, [membership]); } } return allUsers.map((user: any) => ({ ...user, userGroups: groupsByUserId.get(user.id) ?? [], })) as UserWithGroups[]; } async getUsers(): Promise { return await db.select().from(users); } async createUser(userData: UpsertUser): Promise { const [user] = await db.insert(users).values(userData).returning(); return user; } async updateUser(id: string, userData: Partial): Promise { const [user] = await db .update(users) .set({ ...userData, updatedAt: new Date() }) .where(eq(users.id, id)) .returning(); return user; } async deleteUser(id: string): Promise { await db.delete(users).where(eq(users.id, id)); } // Group operations async getGroups(): Promise { return await db.select().from(groups).orderBy(groups.name); } async getGroup(id: number): Promise { const [group] = await db.select().from(groups).where(eq(groups.id, id)); return group; } async createGroup(groupData: InsertGroup): Promise { // Le mot de passe SMTP est chiffré au repos ; il n'est déchiffré qu'au // moment de l'envoi d'un mail (emailService) const values = { ...groupData, smtpPassword: encryptSecret(groupData.smtpPassword) }; const [group] = await db.insert(groups).values(values).returning(); return group; } async updateGroup(id: number, groupData: Partial): Promise { const values = { ...groupData }; if (values.smtpPassword !== undefined) { values.smtpPassword = encryptSecret(values.smtpPassword); } const [group] = await db .update(groups) .set({ ...values, updatedAt: new Date() }) .where(eq(groups.id, id)) .returning(); return group; } async deleteGroup(id: number): Promise { await db.delete(groups).where(eq(groups.id, id)); } // Supplier operations async getSuppliers(): Promise { return await db.select().from(suppliers).orderBy(suppliers.name); } async createSupplier(supplierData: InsertSupplier): Promise { const [supplier] = await db.insert(suppliers).values(supplierData).returning(); return supplier; } async updateSupplier(id: number, supplierData: Partial): Promise { const [supplier] = await db .update(suppliers) .set({ ...supplierData, updatedAt: new Date() }) .where(eq(suppliers.id, id)) .returning(); return supplier; } async deleteSupplier(id: number): Promise { await db.delete(suppliers).where(eq(suppliers.id, id)); } // Contact operations async getContacts(groupIds?: number[]): Promise { if (groupIds && groupIds.length > 0) { return await db.select().from(contacts).where(inArray(contacts.groupId, groupIds)).orderBy(contacts.name); } return await db.select().from(contacts).orderBy(contacts.name); } async createContact(contactData: InsertContact): Promise { const [contact] = await db.insert(contacts).values(contactData).returning(); return contact; } async updateContact(id: number, contactData: Partial): Promise { const [contact] = await db .update(contacts) .set({ ...contactData, updatedAt: new Date() }) .where(eq(contacts.id, id)) .returning(); return contact; } async deleteContact(id: number): Promise { 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: groupReconciliationColumns, 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: groupReconciliationColumns, 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 .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: groupReconciliationColumns, 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)); if (groupIds && groupIds.length > 0) { query = query.where(inArray(orders.groupId, groupIds)); } const baseOrders = await query.orderBy(desc(orders.createdAt)); // 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)); const ordersWithDeliveries = baseOrders.map((order: any) => ({ ...order, deliveries: deliveriesByOrderId.get(order.id) ?? [] })); return ordersWithDeliveries as OrderWithRelations[]; } async getOrdersByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise { let query = 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: groupReconciliationColumns, 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( and( gte(orders.plannedDate, startDate), lte(orders.plannedDate, endDate) ) ); if (groupIds && groupIds.length > 0) { query = query.where( and( gte(orders.plannedDate, startDate), lte(orders.plannedDate, endDate), inArray(orders.groupId, groupIds) ) ); } const baseOrders = await query.orderBy(desc(orders.plannedDate)); // 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)); const ordersWithDeliveries = baseOrders.map((order: any) => ({ ...order, deliveries: deliveriesByOrderId.get(order.id) ?? [] })); return ordersWithDeliveries as OrderWithRelations[]; } async getOrder(id: number): Promise { // La commande et ses livraisons ne dépendent que de l'id : les deux // requêtes partent en parallèle const orderQuery = 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, id)); // Livraisons associées à cette commande const deliveriesQuery = 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: groupReconciliationColumns, 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, id)); const [[order], associatedDeliveries] = await Promise.all([orderQuery, deliveriesQuery]); if (!order) return undefined; return { ...order, deliveries: associatedDeliveries } as OrderWithRelations; } async createOrder(orderData: InsertOrder): Promise { const [order] = await db.insert(orders).values(orderData).returning(); return order; } async updateOrder(id: number, orderData: Partial): Promise { const [order] = await db .update(orders) .set({ ...orderData, updatedAt: new Date() }) .where(eq(orders.id, id)) .returning(); return order; } async deleteOrder(id: number): Promise { await db.delete(orders).where(eq(orders.id, id)); } // Delivery operations async getDeliveries(groupIds?: number[], options?: { status?: string }): Promise { let query = 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: groupReconciliationColumns, 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)); const conditions = []; if (groupIds && groupIds.length > 0) { conditions.push(inArray(deliveries.groupId, groupIds)); } // Filtre de statut optionnel (ex. le rapprochement ne lit que les livraisons livrées) if (options?.status) { conditions.push(eq(deliveries.status, options.status)); } if (conditions.length > 0) { query = query.where(and(...conditions)); } const baseDeliveries = await query.orderBy(desc(deliveries.createdAt)); const deliveriesWithOrders = await this.attachOrdersAndCommentCounts(baseDeliveries); return deliveriesWithOrders as DeliveryWithRelations[]; } async getDeliveriesByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise { let query = 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: groupReconciliationColumns, 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( and( gte(deliveries.scheduledDate, startDate), lte(deliveries.scheduledDate, endDate) ) ); if (groupIds && groupIds.length > 0) { query = query.where( and( gte(deliveries.scheduledDate, startDate), lte(deliveries.scheduledDate, endDate), inArray(deliveries.groupId, groupIds) ) ); } const baseDeliveries = await query.orderBy(desc(deliveries.scheduledDate)); const deliveriesWithOrders = await this.attachOrdersAndCommentCounts(baseDeliveries); return deliveriesWithOrders as DeliveryWithRelations[]; } async getDelivery(id: number): Promise { const [delivery] = 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, notes: deliveries.notes, createdBy: deliveries.createdBy, createdAt: deliveries.createdAt, updatedAt: deliveries.updatedAt, supplier: suppliers, group: groups }) .from(deliveries) .leftJoin(suppliers, eq(deliveries.supplierId, suppliers.id)) .leftJoin(groups, eq(deliveries.groupId, groups.id)) .where(eq(deliveries.id, id)); if (!delivery) return undefined; // Créateur, commande associée et nombre de commentaires ne dépendent que de // la livraison : les trois chargements partent en parallèle. Chacun garde // son try/catch pour qu'un échec n'empêche pas de renvoyer la livraison. const loadCreator = async () => { try { const user = await this.getUser(delivery.createdBy); if (!user) return null; return { id: user.id, firstName: user.firstName, lastName: user.lastName, username: user.username, email: user.email }; } catch (error) { console.warn('⚠️ Could not load creator info:', error); return null; } }; const loadAssociatedOrder = async () => { if (!delivery.orderId) return undefined; try { const orderData = await this.getOrder(delivery.orderId); if (!orderData) return undefined; // CRITICAL FIX: Vérifier que la commande appartient au même magasin que la livraison if (orderData.groupId !== delivery.groupId) { console.error(`❌ PRODUCTION: getDelivery #${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 return undefined; } return orderData; } catch (error) { console.error(`❌ PRODUCTION: Failed to retrieve associated order #${delivery.orderId} for delivery #${id}:`, error); return undefined; } }; // Compter les commentaires de rapprochement pour cette livraison const countComments = async () => { try { const [countResult] = await db .select({ count: sql`count(*)` }) .from(reconciliationComments) .where(eq(reconciliationComments.deliveryId, id)); return Number(countResult?.count || 0); } catch (error) { console.error(`Failed to count reconciliation comments for delivery #${id}:`, error); return 0; } }; const [creator, associatedOrder, commentsCount] = await Promise.all([ loadCreator(), loadAssociatedOrder(), countComments() ]); return { ...delivery, creator, order: associatedOrder, reconciliationCommentsCount: commentsCount } as DeliveryWithRelations; } async createDelivery(deliveryData: InsertDelivery): Promise { // Créer la livraison avec statut "planned" par défaut const deliveryDataWithStatus = { ...deliveryData, status: deliveryData.status || 'planned' // Force planned status by default }; const [delivery] = await db.insert(deliveries).values(deliveryDataWithStatus).returning(); // Si une commande est liée, la marquer comme "planned" (pas delivered!) if (deliveryData.orderId) { try { console.log(`🔗 PRODUCTION: Delivery #${delivery.id} linked to order #${deliveryData.orderId}, updating order status to 'planned'`); // Récupérer la commande actuelle const [currentOrder] = await db .select() .from(orders) .where(eq(orders.id, deliveryData.orderId)); if (currentOrder && currentOrder.status === 'pending') { await db .update(orders) .set({ status: 'planned', updatedAt: new Date() }) .where(eq(orders.id, deliveryData.orderId)); console.log(`✅ PRODUCTION: Order #${deliveryData.orderId} status updated to 'planned'`); } } catch (error) { console.error(`❌ PRODUCTION: Failed to update order #${deliveryData.orderId} status to planned:`, error); } } return delivery; } async updateDelivery(id: number, deliveryData: Partial): Promise { const [delivery] = await db .update(deliveries) .set({ ...deliveryData, updatedAt: new Date() }) .where(eq(deliveries.id, id)) .returning(); // CRITICAL FIX: Si une commande est liée lors de l'édition, la marquer comme "planned" if (deliveryData.orderId) { try { console.log(`🔗 CALENDAR EDIT: Delivery #${delivery.id} linked to order #${deliveryData.orderId}, updating order status to 'planned'`); // Récupérer la commande actuelle const [currentOrder] = await db .select() .from(orders) .where(eq(orders.id, deliveryData.orderId)); if (currentOrder && currentOrder.status === 'pending') { await db .update(orders) .set({ status: 'planned', updatedAt: new Date() }) .where(eq(orders.id, deliveryData.orderId)); console.log(`✅ CALENDAR EDIT: Order #${deliveryData.orderId} status updated to 'planned'`); } else if (currentOrder) { console.log(`ℹ️ CALENDAR EDIT: Order #${deliveryData.orderId} status is '${currentOrder.status}', no update needed`); } } catch (error) { console.error(`❌ CALENDAR EDIT: Failed to update order #${deliveryData.orderId} status to planned:`, error); } } return delivery; } async deleteDelivery(id: number): Promise { await db.delete(deliveries).where(eq(deliveries.id, id)); } async validateDelivery(id: number, blData?: { blNumber: string; blAmount: number }): Promise { const updateData: any = { status: 'delivered', deliveredDate: new Date(), updatedAt: new Date(), }; if (blData) { updateData.blNumber = blData.blNumber; if (blData.blAmount !== undefined) { updateData.blAmount = blData.blAmount.toString(); } } await db.update(deliveries).set(updateData).where(eq(deliveries.id, id)); } async markDeliveryControlValidated(id: number, userId: string): Promise { await db.update(deliveries).set({ controlValidated: true, controlValidatedBy: userId, controlValidatedAt: new Date(), updatedAt: new Date(), }).where(eq(deliveries.id, id)); } // User-Group operations async getUserGroups(userId: string): Promise { return await db.select().from(userGroups).where(eq(userGroups.userId, userId)); } async assignUserToGroup(userGroupData: InsertUserGroup): Promise { const [userGroup] = await db.insert(userGroups).values(userGroupData).returning(); return userGroup; } async removeUserFromGroup(userId: string, groupId: number): Promise { await db.delete(userGroups).where( and(eq(userGroups.userId, userId), eq(userGroups.groupId, groupId)) ); } async removeUserFromAllGroups(userId: string): Promise { await db.delete(userGroups).where(eq(userGroups.userId, userId)); } // Statistics async getMonthlyStats(year: number, month: number, groupIds?: number[]): Promise<{ ordersCount: number; deliveriesCount: number; pendingOrdersCount: number; averageDeliveryTime: number; totalPalettes: number; totalPackages: number; }> { // En mode développement avec MemStorage, retourner des statistiques simulées if (process.env.NODE_ENV === 'development') { return { ordersCount: 12, deliveriesCount: 8, pendingOrdersCount: 3, averageDeliveryTime: 2.5, totalPalettes: 45, totalPackages: 156, }; } // En production, calculer les vraies statistiques const startDate = `${year}-${month.toString().padStart(2, '0')}-01`; const endDate = month === 12 ? `${year + 1}-01-01` : `${year}-${(month + 1).toString().padStart(2, '0')}-01`; try { // Construire les conditions pour le filtrage par groupe - UTILISER LE BON CHAMP DE DATE let ordersWhereCondition = and( gte(orders.plannedDate, startDate), lt(orders.plannedDate, endDate) ); // Pour les livraisons, utiliser deliveredDate pour celles livrées, sinon scheduledDate let deliveriesWhereCondition = and( gte(deliveries.scheduledDate, startDate), lt(deliveries.scheduledDate, endDate) ); // Ajouter le filtre par groupe si spécifié if (groupIds && groupIds.length > 0) { ordersWhereCondition = and(ordersWhereCondition, inArray(orders.groupId, groupIds)); deliveriesWhereCondition = and(deliveriesWhereCondition, inArray(deliveries.groupId, groupIds)); } // Compter les commandes du mois const ordersQuery = db .select({ count: sql`count(*)` }) .from(orders) .where(ordersWhereCondition); // Compter les livraisons du mois const deliveriesQuery = db .select({ count: sql`count(*)` }) .from(deliveries) .where(deliveriesWhereCondition); // Commandes en attente (sans date de livraison ou pas encore livrées) let pendingWhereCondition = eq(orders.status, 'pending'); if (groupIds && groupIds.length > 0) { pendingWhereCondition = and(pendingWhereCondition, inArray(orders.groupId, groupIds)) as any; } const pendingQuery = db .select({ count: sql`count(*)` }) .from(orders) .where(pendingWhereCondition); // Calculer les totaux de palettes et colis du mois (SEULEMENT livraisons delivered) // IMPORTANT: Utiliser deliveredDate pour filtrer par le mois de livraison effective let deliveredWhereCondition = and( gte(deliveries.deliveredDate, sql`${startDate}::timestamp`), lt(deliveries.deliveredDate, sql`${endDate}::timestamp`), eq(deliveries.status, 'delivered'), isNotNull(deliveries.deliveredDate) ); // Ajouter le filtre par groupe pour les livraisons delivered if (groupIds && groupIds.length > 0) { deliveredWhereCondition = and(deliveredWhereCondition, inArray(deliveries.groupId, groupIds)); } // NOUVEAU: Calculer le délai moyen entre la date de commande et la date de livraison // Uniquement pour les livraisons qui ont une commande liée const deliveriesStatsQuery = db .select({ totalPalettes: sql`COALESCE(SUM(CAST(${deliveries.quantity} as INTEGER)), 0)`, totalPackages: sql`COALESCE(COUNT(*), 0)`, avgDelay: sql`COALESCE(AVG(EXTRACT(DAY FROM (${deliveries.deliveredDate} - ${orders.plannedDate}))), 0)` }) .from(deliveries) .innerJoin(orders, eq(deliveries.orderId, orders.id)) .where( and( deliveredWhereCondition, isNotNull(deliveries.orderId), // S'assurer qu'il y a une commande liée isNotNull(orders.plannedDate) // S'assurer que la commande a une date ) ); // Les quatre requêtes sont indépendantes : on les lance en parallèle const [ordersResult, deliveriesResult, pendingResult, deliveriesStatsResult] = await Promise.all([ ordersQuery, deliveriesQuery, pendingQuery, deliveriesStatsQuery ]); const ordersCount = Number(ordersResult[0]?.count || 0); const deliveriesCount = Number(deliveriesResult[0]?.count || 0); const pendingOrdersCount = Number(pendingResult[0]?.count || 0); const totalPalettes = Number(deliveriesStatsResult[0]?.totalPalettes || 0); const totalPackages = Number(deliveriesStatsResult[0]?.totalPackages || 0); const averageDeliveryTime = Number(deliveriesStatsResult[0]?.avgDelay || 0); return { ordersCount, deliveriesCount, pendingOrdersCount, averageDeliveryTime, totalPalettes, totalPackages, }; } catch (error) { console.error('❌ Erreur calcul statistiques:', error); // Retourner des zéros en cas d'erreur pour éviter un crash return { ordersCount: 0, deliveriesCount: 0, pendingOrdersCount: 0, averageDeliveryTime: 0, totalPalettes: 0, totalPackages: 0, }; } } // Statistics annuelles async getYearlyStats(year: number, groupIds?: number[]): Promise<{ ordersCount: number; deliveriesCount: number; pendingOrdersCount: number; averageDeliveryTime: number; totalPalettes: number; totalPackages: number; }> { // En mode développement avec MemStorage, retourner des statistiques simulées if (process.env.NODE_ENV === 'development') { return { ordersCount: 144, // 12 mois * 12 deliveriesCount: 96, // 12 mois * 8 pendingOrdersCount: 18, // 12 mois * 1.5 averageDeliveryTime: 2.8, // Délai moyen annuel totalPalettes: 540, // 12 mois * 45 totalPackages: 1872, // 12 mois * 156 }; } // En production, calculer les vraies statistiques sur l'année const startDate = `${year}-01-01`; const endDate = `${year + 1}-01-01`; try { // Construire les conditions pour le filtrage par groupe let ordersWhereCondition = and( gte(orders.plannedDate, startDate), lt(orders.plannedDate, endDate) ); let deliveriesWhereCondition = and( gte(deliveries.deliveredDate, sql`${startDate}::timestamp`), lt(deliveries.deliveredDate, sql`${endDate}::timestamp`) ); // Ajouter le filtre par groupe si spécifié if (groupIds && groupIds.length > 0) { ordersWhereCondition = and(ordersWhereCondition, inArray(orders.groupId, groupIds)); deliveriesWhereCondition = and(deliveriesWhereCondition, inArray(deliveries.groupId, groupIds)); } // Compter les commandes de l'année const ordersQuery = db .select({ count: sql`count(*)` }) .from(orders) .where(ordersWhereCondition); // Compter les livraisons de l'année const deliveriesQuery = db .select({ count: sql`count(*)` }) .from(deliveries) .where(deliveriesWhereCondition); // Commandes en attente (sans date de livraison ou pas encore livrées) let pendingWhereCondition = eq(orders.status, 'pending'); if (groupIds && groupIds.length > 0) { pendingWhereCondition = and(pendingWhereCondition, inArray(orders.groupId, groupIds)) as any; } const pendingQuery = db .select({ count: sql`count(*)` }) .from(orders) .where(pendingWhereCondition); // Calculer les totaux de palettes et colis de l'année (SEULEMENT livraisons delivered) let deliveredWhereCondition = and( gte(deliveries.deliveredDate, sql`${startDate}::timestamp`), lt(deliveries.deliveredDate, sql`${endDate}::timestamp`), eq(deliveries.status, 'delivered'), isNotNull(deliveries.deliveredDate) ); // Ajouter le filtre par groupe pour les livraisons delivered if (groupIds && groupIds.length > 0) { deliveredWhereCondition = and(deliveredWhereCondition, inArray(deliveries.groupId, groupIds)); } // NOUVEAU: Calculer le délai moyen entre la date de commande et la date de livraison // Uniquement pour les livraisons qui ont une commande liée const deliveriesStatsQuery = db .select({ totalPalettes: sql`COALESCE(SUM(CAST(${deliveries.quantity} as INTEGER)), 0)`, totalPackages: sql`COALESCE(COUNT(*), 0)`, avgDelay: sql`COALESCE(AVG(EXTRACT(DAY FROM (${deliveries.deliveredDate} - ${orders.plannedDate}))), 0)` }) .from(deliveries) .innerJoin(orders, eq(deliveries.orderId, orders.id)) .where( and( deliveredWhereCondition, isNotNull(deliveries.orderId), // S'assurer qu'il y a une commande liée isNotNull(orders.plannedDate) // S'assurer que la commande a une date ) ); // Les quatre requêtes sont indépendantes : on les lance en parallèle const [ordersResult, deliveriesResult, pendingResult, deliveriesStatsResult] = await Promise.all([ ordersQuery, deliveriesQuery, pendingQuery, deliveriesStatsQuery ]); const ordersCount = Number(ordersResult[0]?.count || 0); const deliveriesCount = Number(deliveriesResult[0]?.count || 0); const pendingOrdersCount = Number(pendingResult[0]?.count || 0); const totalPalettes = Number(deliveriesStatsResult[0]?.totalPalettes || 0); const totalPackages = Number(deliveriesStatsResult[0]?.totalPackages || 0); const averageDeliveryTime = Number(deliveriesStatsResult[0]?.avgDelay || 0); return { ordersCount, deliveriesCount, pendingOrdersCount, averageDeliveryTime, totalPalettes, totalPackages, }; } catch (error) { console.error('❌ Erreur calcul statistiques annuelles:', error); // Retourner des zéros en cas d'erreur pour éviter un crash return { ordersCount: 0, deliveriesCount: 0, pendingOrdersCount: 0, averageDeliveryTime: 0, totalPalettes: 0, totalPackages: 0, }; } } // Publicity operations async getPublicities(year?: number, groupIds?: number[]): Promise { let query = db .select({ id: publicities.id, pubNumber: publicities.pubNumber, designation: publicities.designation, startDate: publicities.startDate, endDate: publicities.endDate, year: publicities.year, createdBy: publicities.createdBy, createdAt: publicities.createdAt, updatedAt: publicities.updatedAt, }) .from(publicities); if (year) { query = query.where(eq(publicities.year, year)); } // SAFE: Use simple column ordering instead of SQL CAST which fails in production const results = await query.orderBy(publicities.pubNumber); const publicityIds = results.map((p: any) => p.id); const participations = publicityIds.length > 0 ? await db .select({ publicityId: publicityParticipations.publicityId, groupId: publicityParticipations.groupId, group: groupSummaryColumns, }) .from(publicityParticipations) .leftJoin(groups, eq(publicityParticipations.groupId, groups.id)) .where(inArray(publicityParticipations.publicityId, publicityIds)) : []; // Sort by pubNumber as integer on the server side for consistency const sortedResults = results.sort((a: any, b: any) => { const numA = parseInt(a.pubNumber) || 0; const numB = parseInt(b.pubNumber) || 0; return numA - numB; }); // Participations regroupées par publicité en un seul passage const participationsByPublicityId = new Map(); for (const p of participations as any[]) { const participation = { publicityId: p.publicityId, groupId: p.groupId, group: p.group!, }; const list = participationsByPublicityId.get(p.publicityId); if (list) { list.push(participation); } else { participationsByPublicityId.set(p.publicityId, [participation]); } } return sortedResults.map((publicity: any) => ({ ...publicity, participations: participationsByPublicityId.get(publicity.id) ?? [], })); } async getPublicity(id: number): Promise { const [publicity] = await db.select().from(publicities).where(eq(publicities.id, id)); if (!publicity) return undefined; const participations = await db .select({ publicityId: publicityParticipations.publicityId, groupId: publicityParticipations.groupId, group: groupSummaryColumns, }) .from(publicityParticipations) .leftJoin(groups, eq(publicityParticipations.groupId, groups.id)) .where(eq(publicityParticipations.publicityId, id)); return { ...publicity, participations: participations.map((p: any) => ({ publicityId: p.publicityId, groupId: p.groupId, group: p.group!, })), }; } async createPublicity(publicityData: InsertPublicity): Promise { const [publicity] = await db.insert(publicities).values(publicityData).returning(); return publicity; } async updatePublicity(id: number, publicityData: Partial): Promise { const [publicity] = await db .update(publicities) .set({ ...publicityData, updatedAt: new Date() }) .where(eq(publicities.id, id)) .returning(); return publicity; } async deletePublicity(id: number): Promise { try { // First, delete all participations (defensive approach for production DB constraints) await db.delete(publicityParticipations).where(eq(publicityParticipations.publicityId, id)); // Then delete the publicity itself const deletedPublicity = await db.delete(publicities).where(eq(publicities.id, id)).returning(); if (deletedPublicity.length === 0) { throw new Error(`Publicity with ID ${id} not found`); } } catch (error) { console.error(`❌ [DELETION] Failed to delete publicity ID: ${id}`, error); throw error; } } async getPublicityParticipations(publicityId: number): Promise { return await db .select() .from(publicityParticipations) .where(eq(publicityParticipations.publicityId, publicityId)); } async setPublicityParticipations(publicityId: number, groupIds: number[]): Promise { await db.delete(publicityParticipations).where(eq(publicityParticipations.publicityId, publicityId)); if (groupIds.length > 0) { const participationsData = groupIds.map(groupId => ({ publicityId, groupId, })); await db.insert(publicityParticipations).values(participationsData); } } // NocoDB Configuration operations // Le jeton API est chiffré en base ; les lecteurs (vérification de facture, // page d'admin) reçoivent la valeur en clair comme avant le chiffrement private decryptNocodbConfig(config: T): T { if (!config) return config; try { return { ...config, apiToken: decryptSecret(config.apiToken) } as T; } catch (error) { console.error(`❌ Jeton NocoDB illisible (config ${config.id}):`, error); return { ...config, apiToken: '' } as T; } } async getNocodbConfigs(): Promise { const configs = await db.select().from(nocodbConfig).orderBy(desc(nocodbConfig.createdAt)); return configs.map((c: NocodbConfig) => this.decryptNocodbConfig(c)); } async getNocodbConfig(id: number): Promise { const [config] = await db.select().from(nocodbConfig).where(eq(nocodbConfig.id, id)); return this.decryptNocodbConfig(config); } async getActiveNocodbConfig(): Promise { try { const [config] = await db .select() .from(nocodbConfig) .where(eq(nocodbConfig.isActive, true)) .limit(1); return this.decryptNocodbConfig(config); } catch (error) { console.error('❌ Erreur récupération config NocoDB:', error); return undefined; } } async createNocodbConfig(configData: InsertNocodbConfig): Promise { const values = { ...configData, apiToken: encryptSecret(configData.apiToken) || '' }; const [config] = await db.insert(nocodbConfig).values(values).returning(); return this.decryptNocodbConfig(config); } async updateNocodbConfig(id: number, configData: Partial): Promise { const values = { ...configData }; if (values.apiToken !== undefined) { values.apiToken = encryptSecret(values.apiToken) || ''; } const [config] = await db .update(nocodbConfig) .set({ ...values, updatedAt: new Date() }) .where(eq(nocodbConfig.id, id)) .returning(); return this.decryptNocodbConfig(config); } async deleteNocodbConfig(id: number): Promise { await db.delete(nocodbConfig).where(eq(nocodbConfig.id, id)); } // Invoice Verification Cache operations async getInvoiceVerificationCache(cacheKey: string): Promise { const [cache] = await db .select() .from(invoiceVerificationCache) .where(eq(invoiceVerificationCache.cacheKey, cacheKey)); // ✅ Vérification d'expiration ajoutée pour production if (cache) { const now = new Date(); const expiresAt = new Date(cache.expiresAt); if (now < expiresAt) { return cache; // Cache valide } else { console.log('⏰ [DATABASE-CACHE] Cache expiré, suppression:', { cacheKey, expiredAt: cache.expiresAt }); // Supprimer le cache expiré await db.delete(invoiceVerificationCache) .where(eq(invoiceVerificationCache.cacheKey, cacheKey)); return undefined; // Cache expiré } } return undefined; // Pas de cache } async createInvoiceVerificationCache(cacheData: InsertInvoiceVerificationCache): Promise { try { // Utiliser UPSERT pour gérer les conflits de clés const [cache] = await db .insert(invoiceVerificationCache) .values(cacheData) .onConflictDoUpdate({ target: invoiceVerificationCache.cacheKey, set: { exists: cacheData.exists, matchType: cacheData.matchType, errorMessage: cacheData.errorMessage, supplierName: cacheData.supplierName, invoiceReference: cacheData.invoiceReference, invoiceAmount: cacheData.invoiceAmount, dueDate: cacheData.dueDate, isReconciled: cacheData.isReconciled, cacheHit: cacheData.cacheHit, apiCallTime: cacheData.apiCallTime, updatedAt: new Date() } }) .returning(); return cache; } catch (error) { console.error('❌ Erreur création cache:', error); throw error; } } async saveInvoiceVerificationCache(cacheData: InsertInvoiceVerificationCache): Promise { return this.createInvoiceVerificationCache(cacheData); } async clearExpiredCache(): Promise { await db.delete(invoiceVerificationCache).where( sql`expires_at < NOW()` ); } // 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 .select({ customerOrder: customerOrders, supplier: suppliers, group: groupSummaryColumns }) .from(customerOrders) .leftJoin(suppliers, eq(customerOrders.supplierId, suppliers.id)) .leftJoin(groups, eq(customerOrders.groupId, groups.id)); if (groupIds && groupIds.length > 0) { query = query.where(inArray(customerOrders.groupId, groupIds)); } const results = await query.orderBy(desc(customerOrders.createdAt)); return results.map((row: any) => ({ ...row.customerOrder, supplier: row.supplier, group: row.group, creator: row.creator || { id: row.customerOrder.createdBy, username: 'unknown' } })); } async getCustomerOrder(id: number): Promise { const [result] = await db .select({ customerOrder: customerOrders, supplier: suppliers, group: groups }) .from(customerOrders) .leftJoin(suppliers, eq(customerOrders.supplierId, suppliers.id)) .leftJoin(groups, eq(customerOrders.groupId, groups.id)) .where(eq(customerOrders.id, id)); if (!result) return undefined; return { ...result.customerOrder, supplier: result.supplier, group: result.group, creator: result.creator || { id: result.customerOrder.createdBy, username: 'unknown' } }; } async createCustomerOrder(orderData: InsertCustomerOrder): Promise { const [customerOrder] = await db.insert(customerOrders).values(orderData).returning(); return customerOrder; } async updateCustomerOrder(id: number, orderData: Partial): Promise { const [customerOrder] = await db .update(customerOrders) .set({ ...orderData, updatedAt: new Date() }) .where(eq(customerOrders.id, id)) .returning(); return customerOrder; } async deleteCustomerOrder(id: number): Promise { await db.delete(customerOrders).where(eq(customerOrders.id, id)); } async getPendingClientCalls(groupIds?: number[]): Promise { const conditions = [ eq(customerOrders.customerNotified, false), inArray(customerOrders.status, ['Disponible', 'disponible', 'Arrivé', 'arrivé', 'Prêt', 'prêt']) ]; if (groupIds && groupIds.length > 0) { conditions.push(inArray(customerOrders.groupId, groupIds)); } const results = await db .select({ customerOrder: customerOrders, supplier: suppliers, group: groupSummaryColumns }) .from(customerOrders) .leftJoin(suppliers, eq(customerOrders.supplierId, suppliers.id)) .leftJoin(groups, eq(customerOrders.groupId, groups.id)) .where(and(...conditions)) .orderBy(desc(customerOrders.createdAt)); return results.map((result: any) => ({ ...result.customerOrder, supplier: result.supplier!, group: result.group!, creator: null // Will be populated if needed })); } async markClientCalled(customerOrderId: number, calledBy: string, comment?: string): Promise { const [updated] = await db .update(customerOrders) .set({ customerNotified: true, notifiedAt: new Date(), notifiedComment: comment || null, updatedAt: new Date() }) .where(eq(customerOrders.id, customerOrderId)) .returning(); return updated; } async getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise { let query = db .select({ dlcProduct: dlcProducts, supplier: suppliers, group: groupSummaryColumns }) .from(dlcProducts) .leftJoin(suppliers, eq(dlcProducts.supplierId, suppliers.id)) .leftJoin(groups, eq(dlcProducts.groupId, groups.id)); const conditions = []; if (groupIds && groupIds.length > 0) { conditions.push(inArray(dlcProducts.groupId, groupIds)); } if (filters) { // Handle dynamic status filtering based on expiry date calculation if (filters.status && filters.status !== 'all') { const today = new Date(); today.setHours(0, 0, 0, 0); // Set to start of day for accurate comparison if (filters.status === 'expires_soon') { // Products expiring within 15 days but not expired yet // Ne pas afficher les produits déjà traités (processedUntilExpiry = true) const in15Days = new Date(); in15Days.setDate(today.getDate() + 15); in15Days.setHours(23, 59, 59, 999); // End of day conditions.push( and( gt(dlcProducts.expiryDate, today as any), lte(dlcProducts.expiryDate, in15Days as any), ne(dlcProducts.status, 'valides'), or( isNull(dlcProducts.processedUntilExpiry), eq(dlcProducts.processedUntilExpiry, false) ) ) ); } else if (filters.status === 'expires') { // Products already expired // Ne pas afficher les produits déjà traités (processedUntilExpiry = true) conditions.push( and( lte(dlcProducts.expiryDate, today as any), ne(dlcProducts.status, 'valides'), or( isNull(dlcProducts.processedUntilExpiry), eq(dlcProducts.processedUntilExpiry, false) ) ) ); } else if (filters.status === 'en_cours') { // Products active (more than 15 days until expiry) const in15Days = new Date(); in15Days.setDate(today.getDate() + 15); in15Days.setHours(23, 59, 59, 999); conditions.push( and( gt(dlcProducts.expiryDate, in15Days as any), ne(dlcProducts.status, 'valides') ) ); } else if (filters.status === 'valides') { // Products that are validated regardless of date conditions.push(eq(dlcProducts.status, 'valides')); } } if (filters.supplierId) { conditions.push(eq(dlcProducts.supplierId, filters.supplierId)); } if (filters.search) { conditions.push( or( sql`LOWER(${dlcProducts.productName}) LIKE LOWER(${'%' + filters.search + '%'})`, sql`LOWER(${suppliers.name}) LIKE LOWER(${'%' + filters.search + '%'})` ) ); } } if (conditions.length > 0) { query = query.where(and(...conditions)); } // Tri intelligent : produits actifs (non grisés) EN PREMIER, puis lignes grisées en dernier const results = await query.orderBy( // Priorité : Actifs → Traités → Stock épuisé → Validés sql`CASE WHEN ${dlcProducts.status} != 'valides' AND (${dlcProducts.processedUntilExpiry} IS NULL OR ${dlcProducts.processedUntilExpiry} = false) AND (${dlcProducts.stockEpuise} IS NULL OR ${dlcProducts.stockEpuise} = false) THEN 1 WHEN ${dlcProducts.processedUntilExpiry} = true THEN 2 WHEN ${dlcProducts.stockEpuise} = true THEN 3 WHEN ${dlcProducts.status} = 'valides' THEN 4 ELSE 1 END`, // Priorité 2 : Date d'expiration (croissante) asc(dlcProducts.expiryDate) ); return results.map((row: any) => ({ ...row.dlcProduct, dlcDate: row.dlcProduct.expiryDate, // ✅ Mapping expiryDate vers dlcDate pour frontend supplier: row.supplier, group: row.group, creator: row.creator || { id: row.dlcProduct.createdBy, username: 'unknown' } })); } async getDlcProduct(id: number): Promise { const [result] = await db .select({ dlcProduct: dlcProducts, supplier: suppliers, group: groups }) .from(dlcProducts) .leftJoin(suppliers, eq(dlcProducts.supplierId, suppliers.id)) .leftJoin(groups, eq(dlcProducts.groupId, groups.id)) .where(eq(dlcProducts.id, id)); if (!result) return undefined; return { ...result.dlcProduct, dlcDate: result.dlcProduct.expiryDate, // ✅ Mapping expiryDate vers dlcDate pour frontend supplier: result.supplier, group: result.group, creator: result.creator || { id: result.dlcProduct.createdBy, username: 'unknown' } }; } async createDlcProduct(productData: InsertDlcProduct): Promise { const [dlcProduct] = await db.insert(dlcProducts).values(productData).returning(); return { ...dlcProduct, dlcDate: new Date(dlcProduct.expiryDate) } as DlcProductFrontend; } async updateDlcProduct(id: number, productData: Partial): Promise { const [dlcProduct] = await db .update(dlcProducts) .set({ ...productData, updatedAt: new Date() }) .where(eq(dlcProducts.id, id)) .returning(); return dlcProduct as DlcProductFrontend; } async deleteDlcProduct(id: number): Promise { await db.delete(dlcProducts).where(eq(dlcProducts.id, id)); } async validateDlcProduct(id: number, validatedBy: string): Promise { const [dlcProduct] = await db .update(dlcProducts) .set({ validatedBy, validatedAt: new Date(), status: "valides", updatedAt: new Date() }) .where(eq(dlcProducts.id, id)) .returning(); return dlcProduct as DlcProductFrontend; } async markDlcProductStockEpuise(id: number, markedBy: string): Promise { const [dlcProduct] = await db .update(dlcProducts) .set({ stockEpuise: true, stockEpuiseBy: markedBy, stockEpuiseAt: new Date(), updatedAt: new Date() }) .where(eq(dlcProducts.id, id)) .returning(); return dlcProduct as DlcProductFrontend; } async restoreDlcProductStock(id: number): Promise { const [dlcProduct] = await db .update(dlcProducts) .set({ stockEpuise: false, stockEpuiseBy: null, stockEpuiseAt: null, updatedAt: new Date() }) .where(eq(dlcProducts.id, id)) .returning(); return dlcProduct as DlcProductFrontend; } async markDlcProductAsProcessed(id: number, processedBy: string): Promise { try { // Essayer avec les nouveaux champs si ils existent const [dlcProduct] = await db .update(dlcProducts) .set({ processedAt: new Date(), processedBy: processedBy, processedUntilExpiry: true, updatedAt: new Date() }) .where(eq(dlcProducts.id, id)) .returning(); return dlcProduct as DlcProductFrontend; } catch (error) { // Fallback : juste mettre à jour la date de modification const [dlcProduct] = await db .update(dlcProducts) .set({ updatedAt: new Date() }) .where(eq(dlcProducts.id, id)) .returning(); return dlcProduct as DlcProductFrontend; } } async unmarkDlcProductAsProcessed(id: number): Promise { try { // Essayer avec les nouveaux champs si ils existent const [dlcProduct] = await db .update(dlcProducts) .set({ processedAt: null, processedBy: null, processedUntilExpiry: false, updatedAt: new Date() }) .where(eq(dlcProducts.id, id)) .returning(); return dlcProduct as DlcProductFrontend; } catch (error) { // Fallback : juste mettre à jour la date de modification const [dlcProduct] = await db .update(dlcProducts) .set({ updatedAt: new Date() }) .where(eq(dlcProducts.id, id)) .returning(); return dlcProduct as DlcProductFrontend; } } async getDlcStats(groupIds?: number[]): Promise<{ active: number; expiringSoon: number; expired: number; }> { const today = new Date(); const alertDate = new Date(); alertDate.setDate(today.getDate() + 15); let whereCondition = sql`1 = 1`; if (groupIds && groupIds.length > 0) { whereCondition = inArray(dlcProducts.groupId, groupIds); } // IMPORTANT: Filtrer sur processedUntilExpiry pour ne pas réafficher les produits déjà traités // Les produits "traités" (processedUntilExpiry=true) ne doivent pas apparaître dans les alertes quotidiennes const [stats] = await db .select({ active: sql`COUNT(CASE WHEN ${dlcProducts.expiryDate} > ${today.toISOString().split('T')[0]} AND ${dlcProducts.status} != 'valides' AND (${dlcProducts.processedUntilExpiry} IS NULL OR ${dlcProducts.processedUntilExpiry} = false) AND (${dlcProducts.stockEpuise} IS NULL OR ${dlcProducts.stockEpuise} = false) THEN 1 END)`, expiringSoon: sql`COUNT(CASE WHEN ${dlcProducts.expiryDate} BETWEEN ${today.toISOString().split('T')[0]} AND ${alertDate.toISOString().split('T')[0]} AND ${dlcProducts.status} != 'valides' AND (${dlcProducts.processedUntilExpiry} IS NULL OR ${dlcProducts.processedUntilExpiry} = false) AND (${dlcProducts.stockEpuise} IS NULL OR ${dlcProducts.stockEpuise} = false) THEN 1 END)`, expired: sql`COUNT(CASE WHEN ${dlcProducts.expiryDate} <= ${today.toISOString().split('T')[0]} AND ${dlcProducts.status} != 'valides' AND (${dlcProducts.processedUntilExpiry} IS NULL OR ${dlcProducts.processedUntilExpiry} = false) AND (${dlcProducts.stockEpuise} IS NULL OR ${dlcProducts.stockEpuise} = false) THEN 1 END)` }) .from(dlcProducts) .where(whereCondition); return { active: stats.active || 0, expiringSoon: stats.expiringSoon || 0, expired: stats.expired || 0 }; } async getTasks(groupIds?: number[], userRole?: string): Promise { const today = new Date(); today.setHours(0, 0, 0, 0); let query = db .select({ task: tasks, group: groupSummaryColumns }) .from(tasks) .leftJoin(groups, eq(tasks.groupId, groups.id)); // Appliquer le filtrage par magasin si spécifié let whereConditions = []; if (groupIds && groupIds.length > 0) { whereConditions.push(inArray(tasks.groupId, groupIds)); } // Filtrage par rôle pour la date de départ if (userRole === 'manager' || userRole === 'employee') { // Managers et employés : seulement les tâches dont la date de départ est atteinte ou passée whereConditions.push( or( isNull(tasks.startDate), // Tâches sans date de départ (toujours visibles) lte(tasks.startDate, today) // Tâches dont la date de départ est arrivée ) ); } // Admin et directeur voient toutes les tâches, y compris les tâches programmées if (whereConditions.length > 0) { query = query.where(and(...whereConditions)); } const results = await query.orderBy(desc(tasks.createdAt)); return results .filter((row: any) => row.task) // Filtrer les tâches nulles .map((row: any) => { try { const task = row.task; const group = row.group || null; // Calculer si la tâche est future de manière sécurisée let isFutureTask = false; if (task.startDate) { try { const startDate = new Date(task.startDate); if (!isNaN(startDate.getTime())) { isFutureTask = startDate > today; } } catch (error) { console.warn('Error parsing startDate:', error, task.startDate); } } return { ...task, group, isFutureTask }; } catch (error) { console.error('Error processing task row:', error, row); return null; } }) .filter(Boolean); } async getTask(id: number): Promise { const [result] = await db .select({ task: tasks, group: groups }) .from(tasks) .leftJoin(groups, eq(tasks.groupId, groups.id)) .where(eq(tasks.id, id)); if (!result) return undefined; return { ...result.task, group: result.group }; } async createTask(taskData: InsertTask): Promise { console.log('💾 DatabaseStorage.createTask - Input:', { taskData, dueDate: taskData.dueDate, dueDateType: typeof taskData.dueDate, dueDateISO: taskData.dueDate instanceof Date ? taskData.dueDate.toISOString() : taskData.dueDate }); // Convert string dates to Date objects for Drizzle PostgreSQL const processedTaskData = { ...taskData, startDate: taskData.startDate ? new Date(taskData.startDate) : null, dueDate: taskData.dueDate ? new Date(taskData.dueDate) : null, }; const [task] = await db.insert(tasks).values(processedTaskData).returning(); console.log('💾 DatabaseStorage.createTask - Result:', { taskId: task.id, title: task.title, dueDate: task.dueDate, dueDateType: typeof task.dueDate }); return task; } async updateTask(id: number, taskData: Partial): Promise { console.log('💾 DatabaseStorage.updateTask - Input:', { taskId: id, taskData, dueDate: taskData.dueDate, dueDateType: typeof taskData.dueDate, dueDateISO: taskData.dueDate instanceof Date ? taskData.dueDate.toISOString() : taskData.dueDate }); // Validation des données avant update if (!taskData || typeof taskData !== 'object') { throw new Error('Invalid task data provided'); } // Nettoyer les données pour éviter les problèmes de type const cleanData: any = {}; if (taskData.title !== undefined) cleanData.title = taskData.title; if (taskData.description !== undefined) cleanData.description = taskData.description; if (taskData.priority !== undefined) cleanData.priority = taskData.priority; if (taskData.status !== undefined) cleanData.status = taskData.status; if (taskData.assignedTo !== undefined) cleanData.assignedTo = taskData.assignedTo; // Gestion spéciale des dates selon les types PostgreSQL if (taskData.startDate !== undefined) { if ((taskData.startDate as any) === '' || taskData.startDate === null) { cleanData.startDate = null; } else { // start_date est un timestamp en PostgreSQL - on peut passer une date cleanData.startDate = new Date(taskData.startDate); } } if (taskData.dueDate !== undefined) { if ((taskData.dueDate as any) === '' || taskData.dueDate === null) { cleanData.dueDate = null; } else { // due_date est un timestamp en PostgreSQL - convertir en Date object cleanData.dueDate = new Date(taskData.dueDate); } } cleanData.updatedAt = new Date(); console.log('🧹 Cleaned data for database update:', { ...cleanData, startDateType: cleanData.startDate ? typeof cleanData.startDate : 'null', dueDateType: cleanData.dueDate ? typeof cleanData.dueDate : 'null', startDateIsDate: cleanData.startDate instanceof Date, }); try { const [task] = await db .update(tasks) .set(cleanData) .where(eq(tasks.id, id)) .returning(); console.log('✅ DatabaseStorage.updateTask - Success:', { taskId: task.id, title: task.title, dueDate: task.dueDate, dueDateType: typeof task.dueDate }); return task; } catch (error) { console.error('❌ DatabaseStorage.updateTask - Database Error:', { taskId: id, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, cleanData, originalTaskData: taskData }); throw new Error(`Failed to update task in database: ${error instanceof Error ? error.message : String(error)}`); } } async deleteTask(id: number): Promise { await db.delete(tasks).where(eq(tasks.id, id)); } async completeTask(id: number, completedBy: string): Promise { await db .update(tasks) .set({ status: 'completed', completedAt: new Date(), completedBy, updatedAt: new Date() }) .where(eq(tasks.id, id)); } // Announcement operations async createAnnouncement(announcementData: InsertAnnouncement): Promise { const announcementStorage = getAnnouncementStorage( () => this.getUsers(), () => this.getGroups() ); return await announcementStorage.createAnnouncement(announcementData); } async getAnnouncements(groupIds?: number[]): Promise { const announcementStorage = getAnnouncementStorage( () => this.getUsers(), () => this.getGroups() ); return await announcementStorage.getAnnouncements(groupIds); } async getAnnouncement(id: number): Promise { const announcementStorage = getAnnouncementStorage( () => this.getUsers(), () => this.getGroups() ); return await announcementStorage.getAnnouncement(id); } async updateAnnouncement(id: number, announcementData: Partial): Promise { const announcementStorage = getAnnouncementStorage( () => this.getUsers(), () => this.getGroups() ); return await announcementStorage.updateAnnouncement(id, announcementData); } async deleteAnnouncement(id: number): Promise { const announcementStorage = getAnnouncementStorage( () => this.getUsers(), () => this.getGroups() ); return await announcementStorage.deleteAnnouncement(id); } // Avoir operations async getAvoirs(groupIds?: number[]): Promise { let query = db .select({ id: avoirs.id, supplierId: avoirs.supplierId, groupId: avoirs.groupId, invoiceReference: avoirs.invoiceReference, amount: avoirs.amount, comment: avoirs.comment, commercialProcessed: avoirs.commercialProcessed, status: avoirs.status, webhookSent: avoirs.webhookSent, nocodbVerified: avoirs.nocodbVerified, nocodbVerifiedAt: avoirs.nocodbVerifiedAt, processedAt: avoirs.processedAt, createdBy: avoirs.createdBy, createdAt: avoirs.createdAt, updatedAt: avoirs.updatedAt, supplier: suppliers, group: groupReconciliationColumns, creator: { id: users.id, firstName: users.firstName, lastName: users.lastName, username: users.username, email: users.email } }) .from(avoirs) .leftJoin(suppliers, eq(avoirs.supplierId, suppliers.id)) .leftJoin(groups, eq(avoirs.groupId, groups.id)) .leftJoin(users, eq(avoirs.createdBy, users.id)); if (groupIds && groupIds.length > 0) { query = query.where(inArray(avoirs.groupId, groupIds)); } const results = await query.orderBy(desc(avoirs.createdAt)); return results.map((result: any) => ({ ...result, supplier: result.supplier!, group: result.group!, creator: result.creator!, })); } async getAvoir(id: number): Promise { const [result] = await db .select({ id: avoirs.id, supplierId: avoirs.supplierId, groupId: avoirs.groupId, invoiceReference: avoirs.invoiceReference, amount: avoirs.amount, comment: avoirs.comment, commercialProcessed: avoirs.commercialProcessed, status: avoirs.status, webhookSent: avoirs.webhookSent, nocodbVerified: avoirs.nocodbVerified, nocodbVerifiedAt: avoirs.nocodbVerifiedAt, processedAt: avoirs.processedAt, createdBy: avoirs.createdBy, createdAt: avoirs.createdAt, updatedAt: avoirs.updatedAt, supplier: suppliers, group: groups, creator: { id: users.id, firstName: users.firstName, lastName: users.lastName, username: users.username, email: users.email } }) .from(avoirs) .leftJoin(suppliers, eq(avoirs.supplierId, suppliers.id)) .leftJoin(groups, eq(avoirs.groupId, groups.id)) .leftJoin(users, eq(avoirs.createdBy, users.id)) .where(eq(avoirs.id, id)); if (!result) return undefined; return { ...result, supplier: result.supplier!, group: result.group!, creator: result.creator!, }; } async createAvoir(avoirData: InsertAvoir): Promise { const [avoir] = await db.insert(avoirs).values(avoirData).returning(); return avoir; } async updateAvoir(id: number, avoirData: Partial): Promise { const [avoir] = await db .update(avoirs) .set({ ...avoirData, updatedAt: new Date() }) .where(eq(avoirs.id, id)) .returning(); return avoir; } async deleteAvoir(id: number): Promise { await db.delete(avoirs).where(eq(avoirs.id, id)); } async updateAvoirWebhookStatus(id: number, webhookSent: boolean): Promise { await db .update(avoirs) .set({ webhookSent, updatedAt: new Date() }) .where(eq(avoirs.id, id)); } async updateAvoirNocodbVerification(id: number, verified: boolean): Promise { await db .update(avoirs) .set({ nocodbVerified: verified, nocodbVerifiedAt: verified ? new Date() : null, updatedAt: new Date() }) .where(eq(avoirs.id, id)); } // SAV operations async getSavTickets(filters?: { groupIds?: number[]; status?: string; supplierId?: number; priority?: string; startDate?: string; endDate?: string; }): Promise { let query = db .select({ ticket: savTickets, supplier: suppliers, group: groupSummaryColumns, creator: userSummaryColumns, }) .from(savTickets) .leftJoin(suppliers, eq(savTickets.supplierId, suppliers.id)) .leftJoin(groups, eq(savTickets.groupId, groups.id)) .leftJoin(users, eq(savTickets.createdBy, users.id)); const conditions = []; if (filters?.groupIds?.length) { conditions.push(inArray(savTickets.groupId, filters.groupIds)); } if (filters?.status) { conditions.push(eq(savTickets.status, filters.status)); } if (filters?.supplierId) { conditions.push(eq(savTickets.supplierId, filters.supplierId)); } if (filters?.priority) { conditions.push(eq(savTickets.priority, filters.priority)); } if (filters?.startDate) { conditions.push(gte(savTickets.createdAt, new Date(filters.startDate))); } if (filters?.endDate) { conditions.push(lte(savTickets.createdAt, new Date(filters.endDate))); } if (conditions.length) { query = query.where(and(...conditions)); } const results = await query.orderBy(desc(savTickets.createdAt)); // L'historique SAV est désactivé (getSavTicketHistory renvoie toujours []) : // pas d'appel par ticket. Si on le réactive, le charger en une seule // requête groupée plutôt que ticket par ticket. return results.map((result: any) => ({ ...result.ticket, supplier: result.supplier!, group: result.group!, creator: result.creator!, history: [], })); } async getSavTicket(id: number): Promise { const [result] = await db .select({ ticket: savTickets, supplier: suppliers, group: groups, creator: userSummaryColumns, }) .from(savTickets) .leftJoin(suppliers, eq(savTickets.supplierId, suppliers.id)) .leftJoin(groups, eq(savTickets.groupId, groups.id)) .leftJoin(users, eq(savTickets.createdBy, users.id)) .where(eq(savTickets.id, id)); if (!result) return undefined; const history = await this.getSavTicketHistory(id); return { ...result.ticket, supplier: result.supplier!, group: result.group!, creator: result.creator!, history, }; } async createSavTicket(ticketData: InsertSavTicket): Promise { // Generate ticket number const currentYear = new Date().getFullYear(); // Bornes de l'année plutôt qu'EXTRACT(year ...), pour que l'index sur created_at serve const count = await db .select({ count: sql`count(*)` }) .from(savTickets) .where(sql`${savTickets.createdAt} >= make_date(${currentYear}::int, 1, 1) AND ${savTickets.createdAt} < make_date(${currentYear + 1}::int, 1, 1)`); const ticketNumber = `SAV-${currentYear}-${String((count[0]?.count || 0) + 1).padStart(4, '0')}`; const [ticket] = await db .insert(savTickets) .values({ ...ticketData, ticketNumber }) .returning(); // Add initial history entry // await this.addSavTicketHistory({ // ticketId: ticket.id, // action: 'created', // description: `Ticket créé avec le statut "${ticket.status}"`, // createdBy: ticket.createdBy, // }); return ticket; } async updateSavTicket(id: number, ticketData: Partial): Promise { const [ticket] = await db .update(savTickets) .set({ ...ticketData, updatedAt: new Date() }) .where(eq(savTickets.id, id)) .returning(); return ticket; } async deleteSavTicket(id: number): Promise { // Delete history first // await db.delete(savTicketHistory).where(eq(savTicketHistory.ticketId, id)); // Delete ticket await db.delete(savTickets).where(eq(savTickets.id, id)); } async getSavTicketHistory(ticketId: number): Promise { // const results = await db // .select({ // history: savTicketHistory, // creator: users, // }) // .from(savTicketHistory) // .leftJoin(users, eq(savTicketHistory.createdBy, users.id)) // .where(eq(savTicketHistory.ticketId, ticketId)) // .orderBy(desc(savTicketHistory.createdAt)); // return results.map(result => ({ // ...result.history, // creator: result.creator!, // })); return []; } async addSavTicketHistory(historyData: InsertSavTicketHistory): Promise { // const [history] = await db // .insert(savTicketHistory) // .values(historyData) // .returning(); // return history; return null; } async getSavTicketStats(groupIds?: number[]): Promise<{ totalTickets: number; newTickets: number; inProgressTickets: number; resolvedTickets: number; criticalTickets: number; }> { // Tous les compteurs en une seule requête. « Critiques » compte tous les // tickets de priorité critique, quel que soit leur statut. const [result] = await db .select({ total: sql`count(*)`, newCount: sql`count(*) filter (where ${savTickets.status} = 'nouveau')`, inProgressCount: sql`count(*) filter (where ${savTickets.status} in ('en_cours', 'attente_pieces', 'attente_echange'))`, resolvedCount: sql`count(*) filter (where ${savTickets.status} in ('resolu', 'ferme'))`, criticalCount: sql`count(*) filter (where ${savTickets.priority} = 'critique')` }) .from(savTickets) .where(groupIds?.length ? inArray(savTickets.groupId, groupIds) : undefined); return { totalTickets: Number(result?.total || 0), newTickets: Number(result?.newCount || 0), inProgressTickets: Number(result?.inProgressCount || 0), resolvedTickets: Number(result?.resolvedCount || 0), criticalTickets: Number(result?.criticalCount || 0), }; } // Weather operations async getWeatherSettings(): Promise { const [settings] = await db.select().from(weatherSettings).limit(1); return settings; } async createWeatherSettings(settingsData: InsertWeatherSettings): Promise { const [settings] = await db.insert(weatherSettings).values(settingsData).returning(); return settings; } async updateWeatherSettings(id: number, settingsData: Partial): Promise { const [settings] = await db .update(weatherSettings) .set({ ...settingsData, updatedAt: new Date() }) .where(eq(weatherSettings.id, id)) .returning(); return settings; } async getWeatherData(date: string, isCurrentYear: boolean): Promise { const [data] = await db .select() .from(weatherData) .where(and( eq(weatherData.date, date), eq(weatherData.isCurrentYear, isCurrentYear) )) .limit(1); return data; } async createWeatherData(data: InsertWeatherData): Promise { const [weatherInfo] = await db.insert(weatherData).values(data).returning(); return weatherInfo; } // Repli : ligne en cache la plus proche de la date demandée (à N jours près). // Utilisé quand l'API météo historique échoue (quota, panne) pour continuer // d'afficher la comparaison avec l'année dernière. async getNearestWeatherData(date: string, isCurrentYear: boolean, maxDistanceDays: number): Promise { const [data] = await db .select() .from(weatherData) .where(and( eq(weatherData.isCurrentYear, isCurrentYear), sql`ABS(${weatherData.date} - ${date}::date) <= ${maxDistanceDays}` )) .orderBy(sql`ABS(${weatherData.date} - ${date}::date)`) .limit(1); return data; } async updateWeatherData(id: number, data: Partial): Promise { const [weatherInfo] = await db .update(weatherData) .set({ ...data, updatedAt: new Date() }) .where(eq(weatherData.id, id)) .returning(); return weatherInfo; } async deleteOldWeatherData(daysToKeep: number): Promise { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); await db .delete(weatherData) .where(lt(weatherData.createdAt, cutoffDate)); console.log(`🧹 Deleted weather data older than ${daysToKeep} days`); } async clearWeatherCache(): Promise { await db.delete(weatherData); console.log('🧹 Weather cache cleared due to location change'); } // Historique des mails fournisseurs (rapprochement) async createSupplierMailLog(logData: InsertSupplierMailLog): Promise { const [log] = await db.insert(supplierMailLogs).values(logData).returning(); return log; } async getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise { const conditions = []; if (groupIds && groupIds.length > 0) { conditions.push(inArray(supplierMailLogs.groupId, groupIds)); } if (deliveryId !== undefined) { conditions.push(eq(supplierMailLogs.deliveryId, deliveryId)); } let query = db.select().from(supplierMailLogs).$dynamic(); if (conditions.length > 0) { query = query.where(and(...conditions)); } // Borné : la page de rapprochement n'a besoin que de l'historique récent return await query.orderBy(desc(supplierMailLogs.createdAt)).limit(500); } // Webhook BAP Configuration async getWebhookBapConfig(): Promise { const [config] = await db.select().from(webhookBapConfig).limit(1); return config; } async createWebhookBapConfig(configData: InsertWebhookBapConfig): Promise { const [config] = await db .insert(webhookBapConfig) .values(configData) .returning(); return config; } async updateWebhookBapConfig(id: number, configData: Partial): Promise { const [config] = await db .update(webhookBapConfig) .set({ ...configData, updatedAt: new Date() }) .where(eq(webhookBapConfig.id, id)) .returning(); return config; } // Utilities operations async getUtilities(): Promise { const [result] = await db.select().from(utilities).limit(1); return result; } async createUtilities(utilitiesData: InsertUtilities): Promise { const [result] = await db.insert(utilities).values(utilitiesData).returning(); return result; } async updateUtilities(id: number, utilitiesData: Partial): Promise { const [result] = await db .update(utilities) .set({ ...utilitiesData, updatedAt: new Date() }) .where(eq(utilities.id, id)) .returning(); return result; } // Reconciliation Comments operations async getReconciliationComments(deliveryId: number): Promise { const comments = await db .select({ id: reconciliationComments.id, deliveryId: reconciliationComments.deliveryId, groupId: reconciliationComments.groupId, content: reconciliationComments.content, type: reconciliationComments.type, authorId: reconciliationComments.authorId, createdAt: reconciliationComments.createdAt, updatedAt: reconciliationComments.updatedAt, author: { ...userSummaryColumns, role: users.role }, group: groupSummaryColumns, delivery: { id: deliveries.id, orderId: deliveries.orderId, supplierId: deliveries.supplierId, groupId: deliveries.groupId, scheduledDate: deliveries.scheduledDate, deliveredDate: deliveries.deliveredDate, quantity: deliveries.quantity, unit: deliveries.unit, status: deliveries.status, notes: deliveries.notes, blNumber: deliveries.blNumber, blAmount: deliveries.blAmount, invoiceReference: deliveries.invoiceReference, invoiceAmount: deliveries.invoiceAmount, reconciled: deliveries.reconciled, validatedAt: deliveries.validatedAt, createdBy: deliveries.createdBy, createdAt: deliveries.createdAt, updatedAt: deliveries.updatedAt, } }) .from(reconciliationComments) .leftJoin(users, eq(reconciliationComments.authorId, users.id)) .leftJoin(groups, eq(reconciliationComments.groupId, groups.id)) .leftJoin(deliveries, eq(reconciliationComments.deliveryId, deliveries.id)) .where(eq(reconciliationComments.deliveryId, deliveryId)) .orderBy(desc(reconciliationComments.createdAt)); return comments.map((comment: any) => ({ ...comment, delivery: { ...comment.delivery, supplier: {} as any, // Will be filled by relations if needed group: comment.group!, creator: {} as any, // Will be filled by relations if needed } })) as ReconciliationCommentWithRelations[]; } async getReconciliationCommentById(id: number): Promise { const [comment] = await db .select({ id: reconciliationComments.id, deliveryId: reconciliationComments.deliveryId, groupId: reconciliationComments.groupId, content: reconciliationComments.content, type: reconciliationComments.type, authorId: reconciliationComments.authorId, createdAt: reconciliationComments.createdAt, updatedAt: reconciliationComments.updatedAt, author: { ...userSummaryColumns, role: users.role }, group: groups, delivery: { id: deliveries.id, orderId: deliveries.orderId, supplierId: deliveries.supplierId, groupId: deliveries.groupId, scheduledDate: deliveries.scheduledDate, deliveredDate: deliveries.deliveredDate, quantity: deliveries.quantity, unit: deliveries.unit, status: deliveries.status, notes: deliveries.notes, blNumber: deliveries.blNumber, blAmount: deliveries.blAmount, invoiceReference: deliveries.invoiceReference, invoiceAmount: deliveries.invoiceAmount, reconciled: deliveries.reconciled, validatedAt: deliveries.validatedAt, createdBy: deliveries.createdBy, createdAt: deliveries.createdAt, updatedAt: deliveries.updatedAt, } }) .from(reconciliationComments) .leftJoin(users, eq(reconciliationComments.authorId, users.id)) .leftJoin(groups, eq(reconciliationComments.groupId, groups.id)) .leftJoin(deliveries, eq(reconciliationComments.deliveryId, deliveries.id)) .where(eq(reconciliationComments.id, id)); if (!comment) return undefined; return { ...comment, delivery: { ...comment.delivery, supplier: {} as any, // Will be filled by relations if needed group: comment.group!, creator: {} as any, // Will be filled by relations if needed } } as ReconciliationCommentWithRelations; } async createReconciliationComment(commentData: InsertReconciliationComment): Promise { const [comment] = await db .insert(reconciliationComments) .values(commentData) .returning(); return comment; } async updateReconciliationComment(id: number, commentData: Partial): Promise { const [comment] = await db .update(reconciliationComments) .set({ ...commentData, updatedAt: new Date() }) .where(eq(reconciliationComments.id, id)) .returning(); return comment; } async deleteReconciliationComment(id: number): Promise { await db .delete(reconciliationComments) .where(eq(reconciliationComments.id, id)); } // Analytics implementations async getAnalyticsSummary(filters: { startDate?: Date; endDate?: Date; supplierIds?: number[]; groupIds?: number[]; status?: string[]; }): Promise<{ totalOrders: number; totalDeliveries: number; reconciliationRate: number; totalAmount: number; avgDeliveryDelay: number; topSuppliers: Array<{ id: number; name: string; count: number; amount: number }>; topStores: Array<{ id: number; name: string; orders: number; deliveries: number }>; }> { try { const conditions: any[] = []; const orderConditions: any[] = []; const deliveryConditions: any[] = []; // Apply filters if (filters.startDate) { const startDateStr = filters.startDate.toISOString().split('T')[0]; orderConditions.push(gte(orders.plannedDate, startDateStr)); deliveryConditions.push(gte(deliveries.scheduledDate, startDateStr)); } if (filters.endDate) { const endDateStr = filters.endDate.toISOString().split('T')[0]; orderConditions.push(lte(orders.plannedDate, endDateStr)); deliveryConditions.push(lte(deliveries.scheduledDate, endDateStr)); } if (filters.supplierIds?.length) { orderConditions.push(inArray(orders.supplierId, filters.supplierIds)); deliveryConditions.push(inArray(deliveries.supplierId, filters.supplierIds)); } if (filters.groupIds?.length) { orderConditions.push(inArray(orders.groupId, filters.groupIds)); deliveryConditions.push(inArray(deliveries.groupId, filters.groupIds)); } if (filters.status?.length) { orderConditions.push(inArray(orders.status, filters.status)); deliveryConditions.push(inArray(deliveries.status, filters.status)); } // Get total orders const orderQuery = db.select({ count: sql`COUNT(*)` }).from(orders); if (orderConditions.length) orderQuery.where(and(...orderConditions)); // Get total deliveries and amounts const deliveryQuery = db.select({ count: sql`COUNT(*)`, totalAmount: sql`COALESCE(SUM(CAST(bl_amount AS NUMERIC)), 0) + COALESCE(SUM(CAST(invoice_amount AS NUMERIC)), 0)`, reconciled: sql`COUNT(CASE WHEN reconciled = true THEN 1 END)`, avgDelay: sql`AVG(EXTRACT(EPOCH FROM (delivered_date - scheduled_date)) / 86400)` // days }).from(deliveries); if (deliveryConditions.length) deliveryQuery.where(and(...deliveryConditions)); // Get top suppliers const supplierQuery = db.select({ id: suppliers.id, name: suppliers.name, count: sql`COUNT(${deliveries.id})`, amount: sql`COALESCE(SUM(CAST(${deliveries.blAmount} AS NUMERIC)), 0)` }) .from(deliveries) .innerJoin(suppliers, eq(deliveries.supplierId, suppliers.id)); if (deliveryConditions.length) supplierQuery.where(and(...deliveryConditions)); // Get top stores : commandes et livraisons comptées séparément par magasin // puis jointes, au lieu d'une jointure orders × deliveries qui multipliait // les lignes. Comme avant, seul le filtre magasin s'applique ici. const storeGroupIds = filters.groupIds?.length ? filters.groupIds : null; const storeQuery = db.execute(sql` SELECT g.id AS id, g.name AS name, COALESCE(o.count, 0) AS orders, COALESCE(d.count, 0) AS deliveries FROM groups g LEFT JOIN ( SELECT group_id, COUNT(*) AS count FROM orders ${storeGroupIds ? sql`WHERE group_id IN ${storeGroupIds}` : sql``} GROUP BY group_id ) o ON o.group_id = g.id LEFT JOIN ( SELECT group_id, COUNT(*) AS count FROM deliveries ${storeGroupIds ? sql`WHERE group_id IN ${storeGroupIds}` : sql``} GROUP BY group_id ) d ON d.group_id = g.id ${storeGroupIds ? sql`WHERE g.id IN ${storeGroupIds}` : sql``} ORDER BY COALESCE(o.count, 0) + COALESCE(d.count, 0) DESC LIMIT 5 `); // Les quatre requêtes sont indépendantes : on les lance en parallèle const [[{ count: totalOrders }], [deliveryStats], topSuppliers, storeResult] = await Promise.all([ orderQuery, deliveryQuery, supplierQuery .groupBy(suppliers.id, suppliers.name) .orderBy(desc(sql`COUNT(${deliveries.id})`)) .limit(5), storeQuery ]); const topStores = storeResult.rows; return { totalOrders: Number(totalOrders) || 0, totalDeliveries: Number(deliveryStats.count) || 0, reconciliationRate: deliveryStats.count ? (Number(deliveryStats.reconciled) / Number(deliveryStats.count)) * 100 : 0, totalAmount: Number(deliveryStats.totalAmount) || 0, avgDeliveryDelay: Number(deliveryStats.avgDelay) || 0, topSuppliers: topSuppliers.map((s: any) => ({ id: s.id, name: s.name, count: Number(s.count), amount: Number(s.amount) })), topStores: topStores.map((s: any) => ({ id: s.id, name: s.name, orders: Number(s.orders), deliveries: Number(s.deliveries) })) }; } catch (error) { console.error('Analytics summary error:', error); throw error; } } async getAnalyticsTimeseries(filters: { startDate?: Date; endDate?: Date; supplierIds?: number[]; groupIds?: number[]; granularity?: 'day' | 'week' | 'month'; }): Promise> { const granularity = filters.granularity || 'day'; const dateFormat = granularity === 'day' ? 'YYYY-MM-DD' : granularity === 'week' ? 'YYYY-IW' : 'YYYY-MM'; // Build WHERE clauses with direct value substitution const orderConditions: string[] = []; const deliveryConditions: string[] = []; if (filters.startDate) { const startDate = filters.startDate.toISOString().split('T')[0]; orderConditions.push(`planned_date >= '${startDate}'`); deliveryConditions.push(`scheduled_date >= '${startDate}'`); } if (filters.endDate) { const endDate = filters.endDate.toISOString().split('T')[0]; orderConditions.push(`planned_date <= '${endDate}'`); deliveryConditions.push(`scheduled_date <= '${endDate}'`); } if (filters.supplierIds?.length) { const supplierIds = filters.supplierIds.join(','); orderConditions.push(`supplier_id IN (${supplierIds})`); deliveryConditions.push(`supplier_id IN (${supplierIds})`); } if (filters.groupIds?.length) { const groupIds = filters.groupIds.join(','); orderConditions.push(`group_id IN (${groupIds})`); deliveryConditions.push(`group_id IN (${groupIds})`); } const orderWhere = orderConditions.length > 0 ? `WHERE ${orderConditions.join(' AND ')}` : ''; const deliveryWhere = deliveryConditions.length > 0 ? `WHERE ${deliveryConditions.join(' AND ')}` : ''; // Get orders by date using raw SQL const ordersSql = ` SELECT TO_CHAR(planned_date, '${dateFormat}') as date, COUNT(*) as count FROM orders ${orderWhere} GROUP BY TO_CHAR(planned_date, '${dateFormat}') ORDER BY TO_CHAR(planned_date, '${dateFormat}') `; // Get deliveries by date using raw SQL const deliveriesSql = ` SELECT TO_CHAR(scheduled_date, '${dateFormat}') as date, COUNT(*) as count FROM deliveries ${deliveryWhere} GROUP BY TO_CHAR(scheduled_date, '${dateFormat}') ORDER BY TO_CHAR(scheduled_date, '${dateFormat}') `; // Les deux séries sont indépendantes : on les charge en parallèle const [ordersData, deliveriesData] = await Promise.all([ db.execute(sql.raw(ordersSql)), db.execute(sql.raw(deliveriesSql)) ]); // Merge data const dataMap = new Map(); (ordersData.rows as any[]).forEach(row => { dataMap.set(row.date, { orders: Number(row.count), deliveries: 0 }); }); (deliveriesData.rows as any[]).forEach(row => { const existing = dataMap.get(row.date) || { orders: 0, deliveries: 0 }; dataMap.set(row.date, { ...existing, deliveries: Number(row.count) }); }); return Array.from(dataMap.entries()) .map(([date, data]) => ({ date, ...data })) .sort((a, b) => a.date.localeCompare(b.date)); } async getAnalyticsBySupplier(filters: { startDate?: Date; endDate?: Date; groupIds?: number[]; }): Promise> { const conditions: any[] = []; if (filters.startDate) { const startDateStr = filters.startDate.toISOString().split('T')[0]; conditions.push(gte(deliveries.scheduledDate, startDateStr)); } if (filters.endDate) { const endDateStr = filters.endDate.toISOString().split('T')[0]; conditions.push(lte(deliveries.scheduledDate, endDateStr)); } if (filters.groupIds?.length) conditions.push(inArray(deliveries.groupId, filters.groupIds)); const query = db.select({ supplierId: suppliers.id, supplierName: suppliers.name, deliveries: sql`COUNT(${deliveries.id})`, amount: sql`COALESCE(SUM(CAST(${deliveries.blAmount} AS NUMERIC)), 0)` }) .from(deliveries) .innerJoin(suppliers, eq(deliveries.supplierId, suppliers.id)); if (conditions.length) query.where(and(...conditions)); const result = await query .groupBy(suppliers.id, suppliers.name) .orderBy(desc(sql`COUNT(${deliveries.id})`)); return result.map((row: any) => ({ supplierId: row.supplierId, supplierName: row.supplierName, deliveries: Number(row.deliveries), amount: Number(row.amount) })); } async getAnalyticsByStore(filters: { startDate?: Date; endDate?: Date; supplierIds?: number[]; }): Promise> { try { // Build date filter conditions as strings for raw SQL let orderDateFilter = '1=1'; let deliveryDateFilter = '1=1'; let supplierFilter = '1=1'; if (filters.startDate) { const startDate = filters.startDate.toISOString().split('T')[0]; orderDateFilter += ` AND planned_date >= '${startDate}'`; deliveryDateFilter += ` AND scheduled_date >= '${startDate}'`; } if (filters.endDate) { const endDate = filters.endDate.toISOString().split('T')[0]; orderDateFilter += ` AND planned_date <= '${endDate}'`; deliveryDateFilter += ` AND scheduled_date <= '${endDate}'`; } if (filters.supplierIds?.length) { const supplierIds = filters.supplierIds.join(','); orderDateFilter += ` AND supplier_id IN (${supplierIds})`; deliveryDateFilter += ` AND supplier_id IN (${supplierIds})`; } const result = await db.execute(sql` SELECT g.id as "groupId", g.name as "storeName", COALESCE(o.count, 0) as orders, COALESCE(d.count, 0) as deliveries FROM groups g LEFT JOIN ( SELECT group_id, COUNT(*) as count FROM orders WHERE ${sql.raw(orderDateFilter)} GROUP BY group_id ) o ON g.id = o.group_id LEFT JOIN ( SELECT group_id, COUNT(*) as count FROM deliveries WHERE ${sql.raw(deliveryDateFilter)} GROUP BY group_id ) d ON g.id = d.group_id ORDER BY (COALESCE(o.count, 0) + COALESCE(d.count, 0)) DESC `); const mappedResult = result.rows.map((row: any) => ({ groupId: row.groupId, storeName: row.storeName, orders: Number(row.orders), deliveries: Number(row.deliveries) })); return mappedResult; } catch (error) { console.error('Error in getAnalyticsByStore:', error); return []; } } } // MemStorage class for development export class MemStorage implements IStorage { private users = new Map(); private groups = new Map(); private suppliers = new Map(); private orders = new Map(); private deliveries = new Map(); private userGroups = new Map(); private publicities = new Map(); private publicityParticipations = new Map(); private customerOrders = new Map(); private dlcProducts = new Map(); private tasks = new Map(); private reconciliationComments = new Map(); private announcements = new Map(); private avoirs = new Map(); private savTickets = new Map(); private invoiceVerificationCache = new Map(); private contactsMap = new Map(); private idCounters = { group: 1, supplier: 1, order: 1, delivery: 1, publicity: 1, customerOrder: 1, dlcProduct: 1, task: 1, announcement: 1, avoir: 1, savTicket: 1, reconciliationComment: 1, contact: 1, }; constructor() { this.initializeTestData(); this.initializeExtraTestData(); } private initializeTestData() { // Create admin user const adminUser: User = { id: 'admin', username: 'admin', email: 'admin@example.com', name: 'Admin', firstName: 'Admin', lastName: 'User', profileImageUrl: null, password: 'admin', // In real app, this would be hashed role: 'admin', passwordChanged: false, createdAt: new Date(), updatedAt: new Date(), }; this.users.set(adminUser.id, adminUser); // Initialize group counter this.idCounters.group = 2; // Initialize supplier counter this.idCounters.supplier = 2; // Create test groups const testGroup: Group = { id: 1, name: 'Magasin Test', color: '#3B82F6', nocodbConfigId: 1, // Référence vers la config NocoDB de test nocodbTableName: 'invoices', nocodbTableId: 'mrr733dfb8wtt9b', // ID réel de la table NocoDB invoiceColumnName: 'invoice_reference', nocodbBlColumnName: 'bl_number', nocodbAmountColumnName: 'amount', nocodbSupplierColumnName: 'supplier', webhookUrl: 'https://webhook.test/invoice-upload', createdAt: new Date(), updatedAt: new Date(), }; this.groups.set(testGroup.id, testGroup); this.idCounters.group = 2; // Create test supplier const testSupplier: Supplier = { id: 1, name: 'Fournisseur Test', contact: 'test@supplier.com', phone: '0123456789', hasDlc: false, automaticReconciliation: false, createdAt: new Date(), updatedAt: new Date(), }; this.suppliers.set(testSupplier.id, testSupplier); this.idCounters.supplier = 2; console.log('✅ MemStorage initialized with admin user (admin/admin)'); } // User operations async getUser(id: string): Promise { return this.users.get(id); } async getUserByEmail(email: string): Promise { for (const user of this.users.values()) { if (user.email === email) return user; } return undefined; } async getUserByUsername(username: string): Promise { for (const user of this.users.values()) { if (user.username === username) return user; } return undefined; } async upsertUser(userData: UpsertUser): Promise { const existingUser = this.users.get(userData.id); const user: User = { id: userData.id, username: userData.username, email: userData.email || null, name: userData.name || null, firstName: userData.firstName || null, lastName: userData.lastName || null, profileImageUrl: userData.profileImageUrl || null, password: userData.password || null, role: userData.role || 'user', passwordChanged: userData.passwordChanged || false, createdAt: existingUser?.createdAt || new Date(), updatedAt: new Date(), }; this.users.set(user.id, user); return user; } async getUserWithGroups(id: string): Promise { const user = await this.getUser(id); if (!user) return undefined; const userGroupsList = this.userGroups.get(id) || []; const userGroupsWithGroups = userGroupsList.map(ug => ({ ...ug, group: this.groups.get(ug.groupId)!, })); return { ...user, userGroups: userGroupsWithGroups }; } async getUsersWithGroups(): Promise { return Array.from(this.users.values()).map(({ password: _password, ...user }) => { const userGroupsList = this.userGroups.get(user.id) || []; return { ...user, userGroups: userGroupsList.map(ug => ({ ...ug, group: this.groups.get(ug.groupId)!, })), } as UserWithGroups; }); } async getUsers(): Promise { return Array.from(this.users.values()); } async createUser(userData: UpsertUser): Promise { return this.upsertUser(userData); } async updateUser(id: string, userData: Partial): Promise { const existingUser = this.users.get(id); if (!existingUser) throw new Error('User not found'); const updatedUser = { ...existingUser, ...userData, updatedAt: new Date() }; this.users.set(id, updatedUser); return updatedUser; } async deleteUser(id: string): Promise { this.users.delete(id); this.userGroups.delete(id); } // Group operations async getGroups(): Promise { return Array.from(this.groups.values()); } async getGroup(id: number): Promise { return this.groups.get(id); } async createGroup(groupData: InsertGroup): Promise { const id = this.idCounters.group++; const group: Group = { id, name: groupData.name, color: groupData.color || null, nocodbConfigId: groupData.nocodbConfigId || null, nocodbTableName: groupData.nocodbTableName || null, nocodbTableId: groupData.nocodbTableId || null, invoiceColumnName: groupData.invoiceColumnName || null, nocodbBlColumnName: groupData.nocodbBlColumnName || null, nocodbAmountColumnName: groupData.nocodbAmountColumnName || null, nocodbSupplierColumnName: groupData.nocodbSupplierColumnName || null, webhookUrl: groupData.webhookUrl || null, createdAt: new Date(), updatedAt: new Date(), }; this.groups.set(id, group); return group; } async updateGroup(id: number, groupData: Partial): Promise { const existingGroup = this.groups.get(id); if (!existingGroup) throw new Error('Group not found'); const updatedGroup = { ...existingGroup, ...groupData, updatedAt: new Date() }; this.groups.set(id, updatedGroup); return updatedGroup; } async deleteGroup(id: number): Promise { this.groups.delete(id); } // Supplier operations async getSuppliers(): Promise { return Array.from(this.suppliers.values()); } async createSupplier(supplierData: InsertSupplier): Promise { const id = this.idCounters.supplier++; const supplier: Supplier = { id, name: supplierData.name, contact: supplierData.contact || null, phone: supplierData.phone || null, hasDlc: supplierData.hasDlc || null, automaticReconciliation: supplierData.automaticReconciliation || null, createdAt: new Date(), updatedAt: new Date(), }; this.suppliers.set(id, supplier); return supplier; } async updateSupplier(id: number, supplierData: Partial): Promise { const existingSupplier = this.suppliers.get(id); if (!existingSupplier) throw new Error('Supplier not found'); const updatedSupplier = { ...existingSupplier, ...supplierData, updatedAt: new Date() }; this.suppliers.set(id, updatedSupplier); return updatedSupplier; } async deleteSupplier(id: number): Promise { this.suppliers.delete(id); } // Contact operations async getContacts(groupIds?: number[]): Promise { const all = Array.from(this.contactsMap.values()); if (groupIds && groupIds.length > 0) { return all.filter(c => groupIds.includes(c.groupId)); } return all; } async createContact(contactData: InsertContact): Promise { const id = this.idCounters.contact++; const contact: Contact = { id, groupId: contactData.groupId, name: contactData.name, company: contactData.company || null, role: contactData.role || null, phone: contactData.phone || null, email: contactData.email || null, notes: contactData.notes || null, createdAt: new Date(), updatedAt: new Date(), }; this.contactsMap.set(id, contact); return contact; } async updateContact(id: number, contactData: Partial): Promise { const existing = this.contactsMap.get(id); if (!existing) throw new Error('Contact not found'); const updated = { ...existing, ...contactData, updatedAt: new Date() }; this.contactsMap.set(id, updated); return updated; } async deleteContact(id: number): Promise { this.contactsMap.delete(id); } // NocoDB Configuration methods async getNocodbConfigs(): Promise { return []; // Empty array for development } async getNocodbConfig(id: number): Promise { return undefined; // Not found in development } async getActiveNocodbConfig(): Promise { // Configuration corrigée pour votre serveur NocoDB const testConfig: NocodbConfig = { id: 1, name: 'Configuration Production NocoDB FFNancy', baseUrl: 'https://nocodb.ffnancy.fr', // URL corrigée projectId: 'pcg4uw79ukvycxc', // Project ID de vos logs apiToken: 'xc-token-production-invoice-system', description: 'Configuration de production pour vérification des factures FFNancy', isActive: true, createdBy: 'admin', createdAt: new Date(), updatedAt: new Date(), }; console.log('🔧 Configuration NocoDB active:', { name: testConfig.name, baseUrl: testConfig.baseUrl, projectId: testConfig.projectId, hasToken: !!testConfig.apiToken }); return testConfig; } async createNocodbConfig(config: InsertNocodbConfig): Promise { const newConfig: NocodbConfig = { id: 1, name: config.name, baseUrl: config.baseUrl, projectId: config.projectId, apiToken: config.apiToken, description: config.description || null, isActive: config.isActive ?? true, createdBy: config.createdBy || null, createdAt: new Date(), updatedAt: new Date(), }; return newConfig; } async updateNocodbConfig(id: number, config: Partial): Promise { const updatedConfig: NocodbConfig = { id, name: config.name || 'Test Config', baseUrl: config.baseUrl || 'https://nocodb.example.com', projectId: config.projectId || 'test-project', apiToken: config.apiToken || 'test-token', description: config.description || null, isActive: config.isActive ?? true, createdBy: config.createdBy || null, createdAt: new Date(), updatedAt: new Date(), }; return updatedConfig; } async deleteNocodbConfig(id: number): Promise { // No-op in development } async getInvoiceVerificationCache(cacheKey: string): Promise { // Implémentation temporaire d'un cache en mémoire pour tester const cached = this.invoiceVerificationCache.get(cacheKey); if (cached && new Date() < new Date(cached.expiresAt)) { console.log('✅ [MEMSTORAGE-CACHE] Cache hit pour:', { cacheKey, expires: cached.expiresAt }); return cached; } if (cached && new Date() >= new Date(cached.expiresAt)) { console.log('⏰ [MEMSTORAGE-CACHE] Cache expiré, suppression:', { cacheKey }); this.invoiceVerificationCache.delete(cacheKey); } console.log('❌ [MEMSTORAGE-CACHE] Cache miss pour:', { cacheKey }); return undefined; } async createInvoiceVerificationCache(cache: InsertInvoiceVerificationCache): Promise { const newCache: InvoiceVerificationCache = { id: 1, cacheKey: cache.cacheKey, groupId: cache.groupId, invoiceReference: cache.invoiceReference, supplierName: cache.supplierName || null, invoiceAmount: cache.invoiceAmount || null, dueDate: cache.dueDate || null, exists: cache.exists, matchType: cache.matchType, errorMessage: cache.errorMessage || null, isReconciled: cache.isReconciled || false, cacheHit: cache.cacheHit || false, apiCallTime: cache.apiCallTime || null, expiresAt: cache.expiresAt, createdAt: new Date(), updatedAt: new Date(), }; return newCache; } async saveInvoiceVerificationCache(cache: InsertInvoiceVerificationCache): Promise { const newCache = await this.createInvoiceVerificationCache(cache); // Sauver en mémoire pour le cache temporaire console.log('💾 [MEMSTORAGE-CACHE] Sauvegarde en cache:', { cacheKey: cache.cacheKey, exists: cache.exists, expiresAt: cache.expiresAt }); this.invoiceVerificationCache.set(cache.cacheKey, newCache); return newCache; } async clearExpiredCache(): Promise { // 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()); if (groupIds && groupIds.length > 0) { orders = orders.filter(order => groupIds.includes(order.groupId)); } return orders.map(order => { // Récupérer les livraisons associées à cette commande (DEV RELATIONS) const associatedDeliveries = Array.from(this.deliveries.values()) .filter(delivery => delivery.orderId === order.id) .map(delivery => ({ ...delivery, supplier: this.suppliers.get(delivery.supplierId), group: this.groups.get(delivery.groupId), creator: this.users.get(delivery.createdBy || '') })); return { ...order, supplier: this.suppliers.get(order.supplierId), group: this.groups.get(order.groupId), creator: this.users.get(order.createdBy), deliveries: associatedDeliveries }; }); } async getOrdersByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise { const start = new Date(startDate); const end = new Date(endDate); let orders = Array.from(this.orders.values()).filter(order => { const orderDate = new Date(order.plannedDate); return orderDate >= start && orderDate <= end; }); if (groupIds && groupIds.length > 0) { orders = orders.filter(order => groupIds.includes(order.groupId)); } return orders.map(order => { // Récupérer les livraisons associées à cette commande (DEV RELATIONS) const associatedDeliveries = Array.from(this.deliveries.values()) .filter(delivery => delivery.orderId === order.id) .map(delivery => ({ ...delivery, supplier: this.suppliers.get(delivery.supplierId), group: this.groups.get(delivery.groupId), creator: this.users.get(delivery.createdBy || '') })); return { ...order, supplier: this.suppliers.get(order.supplierId), group: this.groups.get(order.groupId), creator: this.users.get(order.createdBy), deliveries: associatedDeliveries }; }); } async getOrder(id: number): Promise { const order = this.orders.get(id); if (!order) return undefined; // Récupérer les livraisons associées à cette commande const associatedDeliveries = Array.from(this.deliveries.values()) .filter(delivery => delivery.orderId === id) .map(delivery => ({ ...delivery, supplier: this.suppliers.get(delivery.supplierId), group: this.groups.get(delivery.groupId), creator: this.users.get(delivery.createdBy || '') })); return { ...order, supplier: this.suppliers.get(order.supplierId), group: this.groups.get(order.groupId), creator: this.users.get(order.createdBy), deliveries: associatedDeliveries }; } async createOrder(orderData: InsertOrder): Promise { const id = this.idCounters.order++; const order: Order = { id, ...orderData, createdAt: new Date(), updatedAt: new Date(), }; this.orders.set(id, order); return order; } async updateOrder(id: number, orderData: Partial): Promise { const existingOrder = this.orders.get(id); if (!existingOrder) throw new Error('Order not found'); const updatedOrder = { ...existingOrder, ...orderData, updatedAt: new Date(), }; this.orders.set(id, updatedOrder); return updatedOrder; } async deleteOrder(id: number): Promise { this.orders.delete(id); } async getDeliveries(groupIds?: number[], options?: { status?: string }): Promise { let deliveries = Array.from(this.deliveries.values()); if (groupIds && groupIds.length > 0) { deliveries = deliveries.filter(delivery => groupIds.includes(delivery.groupId)); } if (options?.status) { deliveries = deliveries.filter(delivery => delivery.status === options.status); } return deliveries.map(delivery => { // Récupérer la commande associée à cette livraison si elle existe (DEV RELATIONS) let associatedOrder = undefined; if (delivery.orderId) { const order = this.orders.get(delivery.orderId); if (order) { associatedOrder = { ...order, supplier: this.suppliers.get(order.supplierId), group: this.groups.get(order.groupId), creator: this.users.get(order.createdBy) }; } } // Compter les commentaires de rapprochement pour cette livraison const commentsCount = Array.from(this.reconciliationComments.values()) .filter(comment => comment.deliveryId === delivery.id).length; return { ...delivery, supplier: this.suppliers.get(delivery.supplierId), group: this.groups.get(delivery.groupId), creator: this.users.get(delivery.createdBy || ''), order: associatedOrder, reconciliationCommentsCount: commentsCount }; }); } async getDeliveriesByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise { const start = new Date(startDate); const end = new Date(endDate); let deliveries = Array.from(this.deliveries.values()).filter(delivery => { const deliveryDate = new Date(delivery.scheduledDate); return deliveryDate >= start && deliveryDate <= end; }); if (groupIds && groupIds.length > 0) { deliveries = deliveries.filter(delivery => groupIds.includes(delivery.groupId)); } return deliveries.map(delivery => { // Récupérer la commande associée à cette livraison si elle existe (DEV RELATIONS) let associatedOrder = undefined; if (delivery.orderId) { const order = this.orders.get(delivery.orderId); if (order) { associatedOrder = { ...order, supplier: this.suppliers.get(order.supplierId), group: this.groups.get(order.groupId), creator: this.users.get(order.createdBy) }; } } // Compter les commentaires de rapprochement pour cette livraison const commentsCount = Array.from(this.reconciliationComments.values()) .filter(comment => comment.deliveryId === delivery.id).length; return { ...delivery, supplier: this.suppliers.get(delivery.supplierId), group: this.groups.get(delivery.groupId), creator: this.users.get(delivery.createdBy || ''), order: associatedOrder, reconciliationCommentsCount: commentsCount }; }); } async getDelivery(id: number): Promise { const delivery = this.deliveries.get(id); if (!delivery) return undefined; // Récupérer la commande associée si elle existe let associatedOrder = undefined; if (delivery.orderId) { const order = this.orders.get(delivery.orderId); if (order) { associatedOrder = { ...order, supplier: this.suppliers.get(order.supplierId), group: this.groups.get(order.groupId), creator: this.users.get(order.createdBy) }; } } // Compter les commentaires de rapprochement pour cette livraison const commentsCount = Array.from(this.reconciliationComments.values()) .filter(comment => comment.deliveryId === delivery.id).length; return { ...delivery, supplier: this.suppliers.get(delivery.supplierId), group: this.groups.get(delivery.groupId), creator: this.users.get(delivery.createdBy || ''), order: associatedOrder, reconciliationCommentsCount: commentsCount }; } async createDelivery(deliveryData: InsertDelivery): Promise { const id = this.idCounters.delivery++; const delivery: Delivery = { id, ...deliveryData, status: 'planned', // Livraisons créées sont automatiquement planifiées createdAt: new Date(), updatedAt: new Date(), }; this.deliveries.set(id, delivery); // Si une commande est liée, la marquer comme "planned" (pas delivered!) if (deliveryData.orderId) { try { const order = this.orders.get(deliveryData.orderId); if (order && order.status === 'pending') { console.log(`🔗 Delivery #${id} linked to order #${deliveryData.orderId}, updating order status to 'planned'`); await this.updateOrder(deliveryData.orderId, { status: 'planned' }); } } catch (error) { console.error(`❌ Failed to update order #${deliveryData.orderId} status to planned:`, error); } } return delivery; } async updateDelivery(id: number, deliveryData: Partial): Promise { const existingDelivery = this.deliveries.get(id); if (!existingDelivery) throw new Error('Delivery not found'); const updatedDelivery = { ...existingDelivery, ...deliveryData, updatedAt: new Date(), }; this.deliveries.set(id, updatedDelivery); // CRITICAL FIX: Si une commande est liée lors de l'édition, la marquer comme "planned" if (deliveryData.orderId) { try { const order = this.orders.get(deliveryData.orderId); if (order && order.status === 'pending') { console.log(`🔗 CALENDAR EDIT (DEV): Delivery #${id} linked to order #${deliveryData.orderId}, updating order status to 'planned'`); await this.updateOrder(deliveryData.orderId, { status: 'planned' }); console.log(`✅ CALENDAR EDIT (DEV): Order #${deliveryData.orderId} status updated to 'planned'`); } else if (order) { console.log(`ℹ️ CALENDAR EDIT (DEV): Order #${deliveryData.orderId} status is '${order.status}', no update needed`); } } catch (error) { console.error(`❌ CALENDAR EDIT (DEV): Failed to update order #${deliveryData.orderId} status to planned:`, error); } } return updatedDelivery; } async deleteDelivery(id: number): Promise { this.deliveries.delete(id); } async validateDelivery(id: number, blData?: { blNumber: string; blAmount: number }): Promise { const delivery = this.deliveries.get(id); if (!delivery) throw new Error('Delivery not found'); const updatedDelivery = { ...delivery, status: 'delivered' as const, deliveredDate: new Date(), blNumber: blData?.blNumber || delivery.blNumber, blAmount: blData?.blAmount || delivery.blAmount, updatedAt: new Date(), }; this.deliveries.set(id, updatedDelivery); } async getUserGroups(): Promise { return []; } async assignUserToGroup(): Promise { return {} as UserGroup; } async removeUserFromGroup(): Promise { } async removeUserFromAllGroups(userId: string): Promise { this.userGroups.delete(userId); } async getMonthlyStats(): Promise { return { ordersCount: 12, deliveriesCount: 8, pendingOrdersCount: 3, averageDeliveryTime: 2.5, totalPalettes: 45, totalPackages: 156, }; } async getYearlyStats(): Promise { return { ordersCount: 144, // 12 mois * 12 deliveriesCount: 96, // 12 mois * 8 pendingOrdersCount: 18, // 12 mois * 1.5 averageDeliveryTime: 2.8, // Délai moyen annuel totalPalettes: 540, // 12 mois * 45 totalPackages: 1872, // 12 mois * 156 }; } async getPublicities(year?: number, groupIds?: number[]): Promise { let publicities = Array.from(this.publicities.values()); // Filter by year if provided if (year) { publicities = publicities.filter(pub => pub.year === year); } // Sort by pubNumber in ASCENDING order (smallest first) publicities.sort((a, b) => a.pubNumber - b.pubNumber); // LOG: Debug du tri des publicités console.log(`📋 PUBLICITES (DEV): Tri par pubNumber croissant - ${publicities.length} résultats:`, publicities.slice(0, 3).map(p => `N°${p.pubNumber}(${p.designation})`).join(', ') + (publicities.length > 3 ? '...' : '') ); // Return with empty participations for now (since we're using MemStorage) return publicities.map(pub => ({ ...pub, participations: [] })); } async getPublicity(): Promise { return undefined; } async createPublicity(): Promise { return {} as Publicity; } async updatePublicity(): Promise { return {} as Publicity; } async deletePublicity(): Promise { } async getPublicityParticipations(): Promise { return []; } async setPublicityParticipations(): Promise { } async getCustomerOrders(groupIds?: number[]): Promise { let orders = Array.from(this.customerOrders.values()); if (groupIds && groupIds.length > 0) { orders = orders.filter(order => groupIds.includes(order.groupId)); } return orders.map(order => ({ ...order, supplier: this.suppliers.get(order.supplierId)!, group: this.groups.get(order.groupId)!, creator: this.users.get(order.createdBy)! })); } async getCustomerOrder(id: number): Promise { const order = this.customerOrders.get(id); if (!order) return undefined; return { ...order, supplier: this.suppliers.get(order.supplierId)!, group: this.groups.get(order.groupId)!, creator: this.users.get(order.createdBy)! }; } async createCustomerOrder(orderData: InsertCustomerOrder): Promise { const id = this.idCounters.customerOrder++; const order: CustomerOrder = { id, ...orderData, notes: orderData.notes || null, status: orderData.status || 'En attente de Commande', quantity: orderData.quantity || 1, deposit: orderData.deposit || "0.00" as any, isPromotionalPrice: orderData.isPromotionalPrice || false, customerNotified: orderData.customerNotified || false, createdAt: new Date(), updatedAt: new Date(), }; this.customerOrders.set(id, order); return order; } async updateCustomerOrder(id: number, orderData: Partial): Promise { const existingOrder = this.customerOrders.get(id); if (!existingOrder) throw new Error('Customer order not found'); const updatedOrder = { ...existingOrder, ...orderData, updatedAt: new Date() }; this.customerOrders.set(id, updatedOrder); return updatedOrder; } async deleteCustomerOrder(id: number): Promise { this.customerOrders.delete(id); } async getPendingClientCalls(groupIds?: number[]): Promise { let orders = Array.from(this.customerOrders.values()); // Filter by groupIds if provided if (groupIds && groupIds.length > 0) { orders = orders.filter(order => groupIds.includes(order.groupId)); } // Find orders where client not notified and status is 'Disponible' const pendingOrders = orders.filter(order => { return !order.customerNotified && (order.status === 'Disponible' || order.status === 'disponible' || order.status === 'Arrivé' || order.status === 'arrivé' || order.status === 'Prêt' || order.status === 'prêt'); }); return pendingOrders.map(order => ({ ...order, supplier: this.suppliers.get(order.supplierId)!, group: this.groups.get(order.groupId)!, creator: this.users.get(order.createdBy)! })); } async markClientCalled(customerOrderId: number, calledBy: string, comment?: string): Promise { const order = this.customerOrders.get(customerOrderId); if (!order) { throw new Error(`CustomerOrder with id ${customerOrderId} not found`); } const updatedOrder = { ...order, customerNotified: true, updatedAt: new Date() }; this.customerOrders.set(customerOrderId, updatedOrder); return updatedOrder; } async getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise { let products = Array.from(this.dlcProducts.values()); if (groupIds && groupIds.length > 0) { products = products.filter(product => groupIds.includes(product.groupId)); } if (filters) { if (filters.status) { products = products.filter(product => product.status === filters.status); } if (filters.supplierId) { products = products.filter(product => product.supplierId === filters.supplierId); } if (filters.search) { const searchTerm = filters.search.toLowerCase(); products = products.filter(product => { const supplier = this.suppliers.get(product.supplierId); return product.productName.toLowerCase().includes(searchTerm) || (supplier && supplier.name.toLowerCase().includes(searchTerm)); }); } } return products.map(product => ({ ...product, supplier: this.suppliers.get(product.supplierId)!, group: this.groups.get(product.groupId)!, creator: this.users.get(product.createdBy)! })); } async getDlcProduct(id: number): Promise { const product = this.dlcProducts.get(id); if (!product) return undefined; return { ...product, supplier: this.suppliers.get(product.supplierId)!, group: this.groups.get(product.groupId)!, creator: this.users.get(product.createdBy)! }; } async createDlcProduct(productData: InsertDlcProduct): Promise { const id = this.idCounters.dlcProduct++; const product: DlcProduct = { id, ...productData, notes: productData.notes || null, status: productData.status || 'en_cours', quantity: productData.quantity || 1, unit: productData.unit || 'unité', location: productData.location || 'Magasin', alertThreshold: productData.alertThreshold || 15, createdAt: new Date(), updatedAt: new Date(), }; this.dlcProducts.set(id, product); return { ...product, dlcDate: new Date(product.expiryDate) } as DlcProductFrontend; } async updateDlcProduct(id: number, productData: Partial): Promise { const existingProduct = this.dlcProducts.get(id); if (!existingProduct) throw new Error('DLC Product not found'); const updatedProduct = { ...existingProduct, ...productData, updatedAt: new Date() }; this.dlcProducts.set(id, updatedProduct); return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend; } async deleteDlcProduct(id: number): Promise { this.dlcProducts.delete(id); } async validateDlcProduct(id: number, validatedBy: string): Promise { const existingProduct = this.dlcProducts.get(id); if (!existingProduct) throw new Error('DLC Product not found'); const updatedProduct = { ...existingProduct, validatedBy, validatedAt: new Date(), status: "valides", updatedAt: new Date() }; this.dlcProducts.set(id, updatedProduct); return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend; } async markDlcProductStockEpuise(id: number, markedBy: string): Promise { const existingProduct = this.dlcProducts.get(id); if (!existingProduct) throw new Error('DLC Product not found'); const updatedProduct = { ...existingProduct, stockEpuise: true, stockEpuiseBy: markedBy, stockEpuiseAt: new Date(), updatedAt: new Date() }; this.dlcProducts.set(id, updatedProduct); return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend; } async restoreDlcProductStock(id: number): Promise { const existingProduct = this.dlcProducts.get(id); if (!existingProduct) throw new Error('DLC Product not found'); const updatedProduct = { ...existingProduct, stockEpuise: false, stockEpuiseBy: null, stockEpuiseAt: null, updatedAt: new Date() }; this.dlcProducts.set(id, updatedProduct); return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend; } async markDlcProductAsProcessed(id: number, processedBy: string): Promise { const existingProduct = this.dlcProducts.get(id); if (!existingProduct) throw new Error('DLC Product not found'); const updatedProduct = { ...existingProduct, processedAt: new Date(), processedBy: processedBy, processedUntilExpiry: true, updatedAt: new Date() }; this.dlcProducts.set(id, updatedProduct); return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend; } async unmarkDlcProductAsProcessed(id: number): Promise { const existingProduct = this.dlcProducts.get(id); if (!existingProduct) throw new Error('DLC Product not found'); const updatedProduct = { ...existingProduct, processedAt: null, processedBy: null, processedUntilExpiry: false, updatedAt: new Date() }; this.dlcProducts.set(id, updatedProduct); return { ...updatedProduct, dlcDate: new Date(updatedProduct.expiryDate) } as DlcProductFrontend; } async getDlcStats(groupIds?: number[]): Promise<{ active: number; expiringSoon: number; expired: number; }> { const today = new Date(); const alertDate = new Date(); alertDate.setDate(today.getDate() + 15); let products = Array.from(this.dlcProducts.values()); if (groupIds && groupIds.length > 0) { products = products.filter(product => groupIds.includes(product.groupId)); } // IMPORTANT: Filtrer sur processedUntilExpiry pour ne pas réafficher les produits déjà traités // Les produits "traités" (processedUntilExpiry=true) ne doivent pas apparaître dans les alertes quotidiennes return { active: products.filter(p => new Date(p.expiryDate) > today && p.status !== 'valides' && !p.processedUntilExpiry && !p.stockEpuise ).length, expiringSoon: products.filter(p => { const expiry = new Date(p.expiryDate); const isExpiringSoon = expiry >= today && expiry <= alertDate; return isExpiringSoon && p.status !== 'valides' && !p.processedUntilExpiry && !p.stockEpuise; }).length, expired: products.filter(p => { const expiry = new Date(p.expiryDate); const isExpired = expiry <= today; return isExpired && p.status !== 'valides' && !p.processedUntilExpiry && !p.stockEpuise; }).length }; } async getTasks(groupIds?: number[], userRole?: string): Promise { const today = new Date(); today.setHours(0, 0, 0, 0); let tasks = Array.from(this.tasks.values()); if (groupIds && groupIds.length > 0) { tasks = tasks.filter(task => groupIds.includes(task.groupId)); } // Filtrage par rôle pour la date de départ if (userRole === 'manager' || userRole === 'employee') { // Managers et employés : seulement les tâches dont la date de départ est atteinte ou passée tasks = tasks.filter(task => { if (!task.startDate) return true; // Tâches sans date de départ (toujours visibles) const startDate = new Date(task.startDate); return startDate <= today; // Tâches dont la date de départ est arrivée }); } // Admin et directeur voient toutes les tâches, y compris les tâches programmées return tasks.map(task => ({ ...task, group: this.groups.get(task.groupId)!, creator: this.users.get(task.createdBy)!, isFutureTask: task.startDate ? new Date(task.startDate) > today : false })); } async getTask(id: number): Promise { const task = this.tasks.get(id); if (!task) return undefined; return { ...task, group: this.groups.get(task.groupId)!, creator: this.users.get(task.createdBy)! }; } async createTask(taskData: InsertTask): Promise { const id = this.idCounters.task++; const task: Task = { id, ...taskData, description: taskData.description || null, status: taskData.status || 'pending', priority: taskData.priority || 'medium', completedAt: null, completedBy: null, createdAt: new Date(), updatedAt: new Date(), }; this.tasks.set(id, task); return task; } private initializeExtraTestData() { // Add test customer orders const testOrder1: CustomerOrder = { id: 1, supplierId: 1, groupId: 1, createdBy: 'admin', orderTaker: 'Admin', customerName: 'Marie Dupont', customerPhone: '0123456789', customerEmail: null, productDesignation: 'Table de jardin', productReference: null, gencode: null, quantity: 2, deposit: '150.00' as any, status: 'En attente de Commande', isPromotionalPrice: false, customerNotified: false, notes: 'Livraison urgente demandée', createdAt: new Date('2025-01-15'), updatedAt: new Date('2025-01-15'), }; const testOrder2: CustomerOrder = { id: 2, supplierId: 1, groupId: 1, createdBy: 'admin', orderTaker: 'Admin', customerName: 'Pierre Martin', customerPhone: '0987654321', customerEmail: null, productDesignation: 'Chaises pliantes x4', productReference: null, gencode: null, quantity: 1, deposit: '80.00' as any, status: 'Commande passée', isPromotionalPrice: true, customerNotified: true, notes: null, createdAt: new Date('2025-01-10'), updatedAt: new Date('2025-01-12'), }; this.customerOrders.set(1, testOrder1); this.customerOrders.set(2, testOrder2); this.idCounters.customerOrder = 3; // Add test DLC products const testDlc1: DlcProduct = { id: 1, supplierId: 1, groupId: 1, createdBy: 'admin', productName: 'Yaourts bio', gencode: 'YAO001', dateType: 'DLC', expiryDate: '2025-08-20', status: 'en_cours', quantity: 24, unit: 'pièces', notes: 'À vendre rapidement' as string | null, location: 'Frigo principal', alertThreshold: 5, validatedBy: null as string | null, validatedAt: null, stockEpuise: false, stockEpuiseBy: null, stockEpuiseAt: null, createdAt: new Date('2025-08-10'), updatedAt: new Date('2025-08-10'), }; const testDlc2: DlcProduct = { id: 2, supplierId: 1, groupId: 1, createdBy: 'admin', productName: 'Pain de mie complet', gencode: 'PAIN002', dateType: 'DLC', expiryDate: '2025-08-16', status: 'alerte', quantity: 8, unit: 'unités', notes: 'DLC proche, promotion conseillée' as string | null, location: 'Rayonnage A3', alertThreshold: 3, validatedBy: null as string | null, validatedAt: null, stockEpuise: false, stockEpuiseBy: null, stockEpuiseAt: null, createdAt: new Date('2025-08-08'), updatedAt: new Date('2025-08-13'), }; this.dlcProducts.set(1, testDlc1); this.dlcProducts.set(2, testDlc2); this.idCounters.dlcProduct = 3; // Add test tasks const testTask1: Task = { id: 1, groupId: 1, createdBy: 'admin', title: 'Vérifier stock yaourts', description: 'Contrôler les DLC des yaourts et organiser la rotation', status: 'pending', priority: 'high', assignedTo: 'admin', dueDate: new Date('2025-08-15'), completedAt: null, completedBy: null, createdAt: new Date('2025-08-13'), updatedAt: new Date('2025-08-13'), }; const testTask2: Task = { id: 2, groupId: 1, createdBy: 'admin', title: 'Contacter fournisseur', description: 'Appeler le fournisseur pour confirmer la livraison de demain', status: 'in_progress', priority: 'medium', assignedTo: 'admin', dueDate: new Date('2025-08-14'), completedAt: null, completedBy: null, createdAt: new Date('2025-08-12'), updatedAt: new Date('2025-08-13'), }; this.tasks.set(1, testTask1); this.tasks.set(2, testTask2); this.idCounters.task = 3; // Add test publicity data const testPublicity1: Publicity = { id: 1, pubNumber: 2517, designation: 'Prix Fracassés', startDate: new Date('2025-10-27'), endDate: new Date('2025-11-02'), year: 2025, createdBy: 'admin', createdAt: new Date(), updatedAt: new Date(), }; const testPublicity2: Publicity = { id: 2, pubNumber: 2520, designation: 'Halloween', startDate: new Date('2025-10-08'), endDate: new Date('2025-10-12'), year: 2025, createdBy: 'admin', createdAt: new Date(), updatedAt: new Date(), }; const testPublicity3: Publicity = { id: 3, pubNumber: 2521, designation: 'Prix Fous', startDate: new Date('2025-10-20'), endDate: new Date('2025-10-26'), year: 2025, createdBy: 'admin', createdAt: new Date(), updatedAt: new Date(), }; const testPublicity4: Publicity = { id: 4, pubNumber: 2518, designation: 'Le mois anniversaire 1', startDate: new Date('2025-11-17'), endDate: new Date('2025-11-23'), year: 2025, createdBy: 'admin', createdAt: new Date(), updatedAt: new Date(), }; const testPublicity5: Publicity = { id: 5, pubNumber: 2523, designation: 'Noël', startDate: new Date('2025-11-05'), endDate: new Date('2025-11-09'), year: 2025, createdBy: 'admin', createdAt: new Date(), updatedAt: new Date(), }; const testPublicity6: Publicity = { id: 6, pubNumber: 2524, designation: 'Cadeaux et noel', startDate: new Date('2025-11-17'), endDate: new Date('2025-11-23'), year: 2025, createdBy: 'admin', createdAt: new Date(), updatedAt: new Date(), }; const testPublicity7: Publicity = { id: 7, pubNumber: 2525, designation: 'Deco et Table en Fete', startDate: new Date('2025-11-24'), endDate: new Date('2025-11-30'), year: 2025, createdBy: 'admin', createdAt: new Date(), updatedAt: new Date(), }; const testPublicity8: Publicity = { id: 8, pubNumber: 2526, designation: 'Sous le Sapin', startDate: new Date('2025-12-03'), endDate: new Date('2025-12-07'), year: 2025, createdBy: 'admin', createdAt: new Date(), updatedAt: new Date(), }; this.publicities.set(1, testPublicity1); this.publicities.set(2, testPublicity2); this.publicities.set(3, testPublicity3); this.publicities.set(4, testPublicity4); this.publicities.set(5, testPublicity5); this.publicities.set(6, testPublicity6); this.publicities.set(7, testPublicity7); this.publicities.set(8, testPublicity8); this.idCounters.publicity = 9; // Initialize test SAV data this.initializeSavTestData(); } async updateTask(id: number, taskData: Partial): Promise { const existingTask = this.tasks.get(id); if (!existingTask) throw new Error('Task not found'); const updatedTask = { ...existingTask, ...taskData, updatedAt: new Date() }; this.tasks.set(id, updatedTask); return updatedTask; } async deleteTask(id: number): Promise { this.tasks.delete(id); } async completeTask(id: number, completedBy: string): Promise { const existingTask = this.tasks.get(id); if (!existingTask) throw new Error('Task not found'); const updatedTask = { ...existingTask, status: 'completed' as const, completedAt: new Date(), completedBy, updatedAt: new Date() }; this.tasks.set(id, updatedTask); } // SAV operations async getSavTickets(filters?: { groupIds?: number[]; status?: string; supplierId?: number; priority?: string; startDate?: string; endDate?: string; }): Promise { const tickets = Array.from(this.savTickets.values()); let filteredTickets = tickets; if (filters?.groupIds?.length) { filteredTickets = filteredTickets.filter(ticket => filters.groupIds!.includes(ticket.groupId)); } if (filters?.status) { filteredTickets = filteredTickets.filter(ticket => ticket.status === filters.status); } if (filters?.supplierId) { filteredTickets = filteredTickets.filter(ticket => ticket.supplierId === filters.supplierId); } if (filters?.priority) { filteredTickets = filteredTickets.filter(ticket => ticket.priority === filters.priority); } if (filters?.startDate) { const startDate = new Date(filters.startDate); filteredTickets = filteredTickets.filter(ticket => ticket.createdAt && ticket.createdAt >= startDate ); } if (filters?.endDate) { const endDate = new Date(filters.endDate); filteredTickets = filteredTickets.filter(ticket => ticket.createdAt && ticket.createdAt <= endDate ); } // Sort by creation date (newest first) filteredTickets.sort((a, b) => { const dateA = a.createdAt || new Date(0); const dateB = b.createdAt || new Date(0); return dateB.getTime() - dateA.getTime(); }); // Add relations for each ticket const ticketsWithRelations = await Promise.all( filteredTickets.map(async (ticket) => { const supplier = this.suppliers.get(ticket.supplierId); const group = this.groups.get(ticket.groupId); const creator = this.users.get(ticket.createdBy); const history = await this.getSavTicketHistory(ticket.id); return { ...ticket, supplier: supplier!, group: group!, creator: creator!, history, }; }) ); return ticketsWithRelations; } async getSavTicket(id: number): Promise { const ticket = this.savTickets.get(id); if (!ticket) return undefined; const supplier = this.suppliers.get(ticket.supplierId); const group = this.groups.get(ticket.groupId); const creator = this.users.get(ticket.createdBy); const history = await this.getSavTicketHistory(id); return { ...ticket, supplier: supplier!, group: group!, creator: creator!, history, }; } async createSavTicket(ticketData: InsertSavTicket): Promise { const id = this.idCounters.savTicket++; // Generate ticket number const currentYear = new Date().getFullYear(); const ticketNumber = `SAV-${currentYear}-${String(id).padStart(4, '0')}`; const ticket: SavTicket = { id, ticketNumber, ...ticketData, createdAt: new Date(), updatedAt: new Date(), resolvedAt: null, closedAt: null, }; this.savTickets.set(id, ticket); // Add initial history entry await this.addSavTicketHistory({ ticketId: id, action: 'created', description: `Ticket créé avec le statut "${ticket.status}"`, createdBy: ticket.createdBy, }); return ticket; } async updateSavTicket(id: number, ticketData: Partial): Promise { const existingTicket = this.savTickets.get(id); if (!existingTicket) throw new Error('SAV ticket not found'); const updatedTicket = { ...existingTicket, ...ticketData, updatedAt: new Date() }; // Set resolved/closed dates based on status if (ticketData.status === 'resolu' && !updatedTicket.resolvedAt) { updatedTicket.resolvedAt = new Date(); } if (ticketData.status === 'ferme' && !updatedTicket.closedAt) { updatedTicket.closedAt = new Date(); } this.savTickets.set(id, updatedTicket); return updatedTicket; } async deleteSavTicket(id: number): Promise { // Delete history first // this.savTicketHistories.delete(id); // Delete ticket this.savTickets.delete(id); } async getSavTicketHistory(ticketId: number): Promise { return []; } async addSavTicketHistory(historyData: InsertSavTicketHistory): Promise { return null; } async getSavTicketStats(groupIds?: number[]): Promise<{ totalTickets: number; newTickets: number; inProgressTickets: number; resolvedTickets: number; criticalTickets: number; }> { let tickets = Array.from(this.savTickets.values()); if (groupIds?.length) { tickets = tickets.filter(ticket => groupIds.includes(ticket.groupId)); } const stats = { totalTickets: tickets.length, newTickets: tickets.filter(t => t.status === 'nouveau').length, inProgressTickets: tickets.filter(t => ['en_cours', 'attente_pieces', 'attente_echange'].includes(t.status) ).length, resolvedTickets: tickets.filter(t => ['resolu', 'ferme'].includes(t.status) ).length, criticalTickets: tickets.filter(t => t.priority === 'critique').length, }; return stats; } private initializeSavTestData() { // Test SAV tickets const testTicket1: SavTicket = { id: 1, ticketNumber: 'SAV-2025-0001', groupId: 1, supplierId: 1, clientName: 'Jean Dupont', clientPhone: '03 83 12 34 56', problemDescription: 'Produit défaillant après 2 jours d\'utilisation. Écran cassé et boutons non fonctionnels.', productGencode: '12345678901234', productReference: 'REF-ABC-123', productDesignation: 'Tablette Samsung Galaxy Tab A7', status: 'nouveau', priority: 'haute', createdBy: 'admin', createdAt: new Date('2025-08-10T08:30:00'), updatedAt: new Date('2025-08-10T08:30:00'), resolvedAt: null, closedAt: null, }; const testTicket2: SavTicket = { id: 2, ticketNumber: 'SAV-2025-0002', groupId: 1, supplierId: 2, clientName: 'Marie Martin', clientPhone: '06 12 34 56 78', problemDescription: 'Pièces manquantes dans le colis. Manque le chargeur et les écouteurs.', productGencode: '23456789012345', productReference: 'REF-XYZ-456', productDesignation: 'Smartphone iPhone 14', status: 'en_cours', priority: 'normale', createdBy: 'admin', createdAt: new Date('2025-08-09T14:15:00'), updatedAt: new Date('2025-08-12T10:20:00'), resolvedAt: null, closedAt: null, }; const testTicket3: SavTicket = { id: 3, ticketNumber: 'SAV-2025-0003', groupId: 1, supplierId: 1, clientName: 'Pierre Durand', clientPhone: '03 83 98 76 54', problemDescription: 'En attente de la pièce de rechange. Écran de remplacement commandé chez le fournisseur.', productGencode: '34567890123456', productReference: 'REF-DEF-789', productDesignation: 'Ordinateur portable Dell Inspiron', status: 'attente_pieces', priority: 'normale', createdBy: 'admin', createdAt: new Date('2025-08-08T09:45:00'), updatedAt: new Date('2025-08-13T16:30:00'), resolvedAt: null, closedAt: null, }; const testTicket4: SavTicket = { id: 4, ticketNumber: 'SAV-2025-0004', groupId: 1, supplierId: 3, clientName: 'Sophie Moreau', clientPhone: '06 98 76 54 32', problemDescription: 'Problème résolu. Produit réparé et testé. Prêt pour le retour client.', productGencode: '45678901234567', productReference: 'REF-GHI-012', productDesignation: 'Casque Sony WH-1000XM4', status: 'resolu', priority: 'faible', createdBy: 'admin', createdAt: new Date('2025-08-05T11:20:00'), updatedAt: new Date('2025-08-14T08:00:00'), resolvedAt: new Date('2025-08-14T08:00:00'), closedAt: null, }; const testTicket5: SavTicket = { id: 5, ticketNumber: 'SAV-2025-0005', groupId: 1, supplierId: 2, clientName: 'Luc Bernard', clientPhone: '03 83 45 67 89', problemDescription: 'URGENT - Produit en panne critique pour activité professionnelle. Besoin intervention rapide.', productGencode: '56789012345678', productReference: 'REF-JKL-345', productDesignation: 'Imprimante HP LaserJet Pro', status: 'en_cours', priority: 'critique', createdBy: 'admin', createdAt: new Date('2025-08-14T07:30:00'), updatedAt: new Date('2025-08-14T07:30:00'), resolvedAt: null, closedAt: null, }; // Store the tickets this.savTickets.set(1, testTicket1); this.savTickets.set(2, testTicket2); this.savTickets.set(3, testTicket3); this.savTickets.set(4, testTicket4); this.savTickets.set(5, testTicket5); // Add history entries for each ticket const histories = [ // Ticket 1 history { ticketId: 1, action: 'created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-10T08:30:00') }, // Ticket 2 history { ticketId: 2, action: 'created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-09T14:15:00') }, { ticketId: 2, action: 'status_change', description: 'Statut changé de "nouveau" vers "en_cours"', createdBy: 'admin', createdAt: new Date('2025-08-12T10:20:00') }, { ticketId: 2, action: 'comment', description: 'Contact avec le client pour confirmer les pièces manquantes. Commande des accessoires en cours.', createdBy: 'admin', createdAt: new Date('2025-08-12T10:25:00') }, // Ticket 3 history { ticketId: 3, action: 'created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-08T09:45:00') }, { ticketId: 3, action: 'status_change', description: 'Statut changé de "nouveau" vers "en_cours"', createdBy: 'admin', createdAt: new Date('2025-08-10T14:00:00') }, { ticketId: 3, action: 'status_change', description: 'Statut changé de "en_cours" vers "attente_pieces"', createdBy: 'admin', createdAt: new Date('2025-08-13T16:30:00') }, { ticketId: 3, action: 'comment', description: 'Écran de remplacement commandé. Délai annoncé de 5-7 jours ouvrables.', createdBy: 'admin', createdAt: new Date('2025-08-13T16:35:00') }, // Ticket 4 history { ticketId: 4, action: 'created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-05T11:20:00') }, { ticketId: 4, action: 'status_change', description: 'Statut changé de "nouveau" vers "en_cours"', createdBy: 'admin', createdAt: new Date('2025-08-06T09:00:00') }, { ticketId: 4, action: 'comment', description: 'Diagnostic effectué : problème de connectivité Bluetooth. Réparation en cours.', createdBy: 'admin', createdAt: new Date('2025-08-07T15:30:00') }, { ticketId: 4, action: 'status_change', description: 'Statut changé de "en_cours" vers "resolu"', createdBy: 'admin', createdAt: new Date('2025-08-14T08:00:00') }, { ticketId: 4, action: 'comment', description: 'Casque réparé avec succès. Module Bluetooth remplacé. Tests complets effectués.', createdBy: 'admin', createdAt: new Date('2025-08-14T08:05:00') }, // Ticket 5 history { ticketId: 5, action: 'created', description: 'Ticket créé avec le statut "nouveau"', createdBy: 'admin', createdAt: new Date('2025-08-14T07:30:00') }, { ticketId: 5, action: 'status_change', description: 'Statut changé de "nouveau" vers "en_cours"', createdBy: 'admin', createdAt: new Date('2025-08-14T07:30:00') }, { ticketId: 5, action: 'comment', description: 'Priorité critique - Intervention d\'urgence programmée pour ce matin.', createdBy: 'admin', createdAt: new Date('2025-08-14T07:35:00') }, ]; // Initialize history for each ticket // histories.forEach((historyEntry) => { // const id = this.idCounters.savTicketHistory++; // const history: SavTicketHistory = { // id, // ...historyEntry, // }; // const existingHistories = this.savTicketHistories.get(historyEntry.ticketId) || []; // existingHistories.push(history); // this.savTicketHistories.set(historyEntry.ticketId, existingHistories); // }); this.idCounters.savTicket = 6; console.log('✅ SAV test data initialized with 5 tickets'); } // Weather operations async getWeatherSettings(): Promise { // In memory storage, return a default setting for development return { id: 1, apiKey: "6PSHYLBJB9EEYNZ7NDQ5BPGBZ", // Token fourni par l'utilisateur location: "Nancy, France", isActive: true, createdAt: new Date(), updatedAt: new Date() }; } async createWeatherSettings(settings: InsertWeatherSettings): Promise { // For development, we'll just return the created settings with an ID return { id: 1, ...settings, createdAt: new Date(), updatedAt: new Date() }; } async updateWeatherSettings(id: number, settings: Partial): Promise { const existing = await this.getWeatherSettings(); return { ...existing!, ...settings, updatedAt: new Date() }; } async getWeatherData(date: string, isCurrentYear: boolean): Promise { // In development, return some mock data for testing return undefined; // Will trigger API calls } async getNearestWeatherData(date: string, isCurrentYear: boolean, maxDistanceDays: number): Promise { return undefined; } async createWeatherData(data: InsertWeatherData): Promise { return { id: 1, ...data, createdAt: new Date(), updatedAt: new Date() }; } async updateWeatherData(id: number, data: Partial): Promise { const existing = await this.getWeatherData(data.date || "", data.isCurrentYear || true); return { ...existing!, ...data, updatedAt: new Date() }; } async deleteOldWeatherData(daysToKeep: number): Promise { // In development, this is a no-op console.log(`🧹 DEV: Would delete weather data older than ${daysToKeep} days`); } async clearWeatherCache(): Promise { // In development, this is a no-op console.log('🧹 DEV: Weather cache cleared due to location change'); } async markDeliveryControlValidated(id: number, userId: string): Promise { const delivery = this.deliveries.get(id); if (delivery) { this.deliveries.set(id, { ...delivery, controlValidated: true, controlValidatedBy: userId, controlValidatedAt: new Date(), updatedAt: new Date(), }); } } // Historique des mails fournisseurs (en mémoire pour le développement) private supplierMailLogsStore: SupplierMailLog[] = []; private supplierMailLogIdCounter = 1; async createSupplierMailLog(logData: InsertSupplierMailLog): Promise { const log: SupplierMailLog = { id: this.supplierMailLogIdCounter++, deliveryId: logData.deliveryId, groupId: logData.groupId, supplierId: logData.supplierId ?? null, supplierName: logData.supplierName ?? null, sentTo: logData.sentTo, subject: logData.subject ?? null, status: logData.status, errorMessage: logData.errorMessage ?? null, messageId: logData.messageId ?? null, sentBy: logData.sentBy, sentByName: logData.sentByName ?? null, createdAt: new Date(), }; this.supplierMailLogsStore.unshift(log); return log; } async getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise { return this.supplierMailLogsStore .filter(log => (!groupIds || groupIds.length === 0 || groupIds.includes(log.groupId)) && (deliveryId === undefined || log.deliveryId === deliveryId) ) .slice(0, 500); } // Webhook BAP Configuration async getWebhookBapConfig(): Promise { // En développement, retourner une config par défaut return { id: 1, name: "Configuration BAP", webhookUrl: "https://workflow.ffnancy.fr/webhook/a3d03176-b72f-412d-8fb9-f920b9fbab4d", description: "Configuration par défaut pour développement", isActive: true, createdAt: new Date(), updatedAt: new Date() }; } async createWebhookBapConfig(config: InsertWebhookBapConfig): Promise { return { id: 1, ...config, createdAt: new Date(), updatedAt: new Date() }; } async updateWebhookBapConfig(id: number, config: Partial): Promise { const existing = await this.getWebhookBapConfig(); return { ...existing!, ...config, updatedAt: new Date() }; } // Utilities operations async getUtilities(): Promise { // MemStorage ne stocke pas les utilities, retourne undefined return undefined; } async createUtilities(utilitiesData: InsertUtilities): Promise { // MemStorage ne supporte pas la création de utilities return { id: 1, salesAnalysisUrl: utilitiesData.salesAnalysisUrl ?? null, createdAt: new Date(), updatedAt: new Date() }; } async updateUtilities(id: number, utilitiesData: Partial): Promise { // MemStorage ne supporte pas la mise à jour de utilities return { id, salesAnalysisUrl: utilitiesData.salesAnalysisUrl ?? null, createdAt: new Date(), updatedAt: new Date() }; } // Announcement operations async createAnnouncement(announcementData: InsertAnnouncement): Promise { const announcementStorage = getAnnouncementStorage( async () => Array.from(this.users.values()), async () => Array.from(this.groups.values()) ); return await announcementStorage.createAnnouncement(announcementData); } async getAnnouncements(groupIds?: number[]): Promise { const announcementStorage = getAnnouncementStorage( async () => Array.from(this.users.values()), async () => Array.from(this.groups.values()) ); return await announcementStorage.getAnnouncements(groupIds); } async getAnnouncement(id: number): Promise { const announcementStorage = getAnnouncementStorage( async () => Array.from(this.users.values()), async () => Array.from(this.groups.values()) ); return await announcementStorage.getAnnouncement(id); } async updateAnnouncement(id: number, announcementData: Partial): Promise { const announcementStorage = getAnnouncementStorage( async () => Array.from(this.users.values()), async () => Array.from(this.groups.values()) ); return await announcementStorage.updateAnnouncement(id, announcementData); } async deleteAnnouncement(id: number): Promise { const announcementStorage = getAnnouncementStorage( async () => Array.from(this.users.values()), async () => Array.from(this.groups.values()) ); return await announcementStorage.deleteAnnouncement(id); } // Avoir operations async getAvoirs(groupIds?: number[]): Promise { let avoirs = Array.from(this.avoirs.values()); // Filter by groups if provided if (groupIds && groupIds.length > 0) { avoirs = avoirs.filter(avoir => groupIds.includes(avoir.groupId)); } return avoirs .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .map(avoir => ({ ...avoir, supplier: this.suppliers.get(avoir.supplierId)!, group: this.groups.get(avoir.groupId)!, creator: this.users.get(avoir.createdBy)!, })); } async getAvoir(id: number): Promise { const avoir = this.avoirs.get(id); if (!avoir) return undefined; return { ...avoir, supplier: this.suppliers.get(avoir.supplierId)!, group: this.groups.get(avoir.groupId)!, creator: this.users.get(avoir.createdBy)!, }; } async createAvoir(avoirData: InsertAvoir): Promise { const id = this.idCounters.avoir++; const avoir: Avoir = { id, ...avoirData, status: 'En attente de demande', webhookSent: false, nocodbVerified: false, nocodbVerifiedAt: null, processedAt: null, createdAt: new Date(), updatedAt: new Date(), }; this.avoirs.set(id, avoir); return avoir; } async updateAvoir(id: number, avoirData: Partial): Promise { const existingAvoir = this.avoirs.get(id); if (!existingAvoir) throw new Error('Avoir not found'); const updatedAvoir = { ...existingAvoir, ...avoirData, updatedAt: new Date(), }; this.avoirs.set(id, updatedAvoir); return updatedAvoir; } async deleteAvoir(id: number): Promise { this.avoirs.delete(id); } async updateAvoirWebhookStatus(id: number, webhookSent: boolean): Promise { const avoir = this.avoirs.get(id); if (avoir) { avoir.webhookSent = webhookSent; avoir.updatedAt = new Date(); this.avoirs.set(id, avoir); } } async updateAvoirNocodbVerification(id: number, verified: boolean): Promise { const avoir = this.avoirs.get(id); if (avoir) { avoir.nocodbVerified = verified; avoir.nocodbVerifiedAt = verified ? new Date() : null; avoir.updatedAt = new Date(); this.avoirs.set(id, avoir); } } // Reconciliation Comments operations async getReconciliationComments(deliveryId: number): Promise { const comments = Array.from(this.reconciliationComments.values()) .filter(comment => comment.deliveryId === deliveryId) .sort((a, b) => new Date(b.createdAt!).getTime() - new Date(a.createdAt!).getTime()); return comments.map(comment => { const delivery = this.deliveries.get(comment.deliveryId); const author = this.users.get(comment.authorId); const group = this.groups.get(comment.groupId); if (!delivery || !author || !group) { throw new Error('Missing related data for reconciliation comment'); } const supplier = this.suppliers.get(delivery.supplierId); const creator = this.users.get(delivery.createdBy); if (!supplier || !creator) { throw new Error('Missing delivery related data for reconciliation comment'); } return { ...comment, delivery: { ...delivery, supplier, group, creator, }, group, author, } as ReconciliationCommentWithRelations; }); } async getReconciliationCommentById(id: number): Promise { const comment = this.reconciliationComments.get(id); if (!comment) return undefined; const delivery = this.deliveries.get(comment.deliveryId); const author = this.users.get(comment.authorId); const group = this.groups.get(comment.groupId); if (!delivery || !author || !group) { throw new Error('Missing related data for reconciliation comment'); } const supplier = this.suppliers.get(delivery.supplierId); const creator = this.users.get(delivery.createdBy); if (!supplier || !creator) { throw new Error('Missing delivery related data for reconciliation comment'); } return { ...comment, delivery: { ...delivery, supplier, group, creator, }, group, author, } as ReconciliationCommentWithRelations; } async createReconciliationComment(commentData: InsertReconciliationComment): Promise { const id = this.idCounters.reconciliationComment++; const comment: ReconciliationComment = { id, ...commentData, createdAt: new Date(), updatedAt: new Date(), }; this.reconciliationComments.set(id, comment); return comment; } async updateReconciliationComment(id: number, commentData: Partial): Promise { const existingComment = this.reconciliationComments.get(id); if (!existingComment) throw new Error('Reconciliation comment not found'); const updatedComment = { ...existingComment, ...commentData, updatedAt: new Date(), }; this.reconciliationComments.set(id, updatedComment); return updatedComment; } async deleteReconciliationComment(id: number): Promise { this.reconciliationComments.delete(id); } // Analytics implementations for MemStorage async getAnalyticsSummary(filters: { startDate?: Date; endDate?: Date; supplierIds?: number[]; groupIds?: number[]; status?: string[]; }): Promise<{ totalOrders: number; totalDeliveries: number; reconciliationRate: number; totalAmount: number; avgDeliveryDelay: number; topSuppliers: Array<{ id: number; name: string; count: number; amount: number }>; topStores: Array<{ id: number; name: string; orders: number; deliveries: number }>; }> { // Simple implementation for development const orders = Array.from(this.orders.values()); const deliveries = Array.from(this.deliveries.values()); // Apply filters const filteredOrders = orders.filter(order => { if (filters.startDate) { const orderDate = new Date(order.plannedDate); if (orderDate < filters.startDate) return false; } if (filters.endDate) { const orderDate = new Date(order.plannedDate); if (orderDate > filters.endDate) return false; } if (filters.supplierIds?.length && !filters.supplierIds.includes(order.supplierId)) return false; if (filters.groupIds?.length && !filters.groupIds.includes(order.groupId)) return false; if (filters.status?.length && !filters.status.includes(order.status)) return false; return true; }); const filteredDeliveries = deliveries.filter(delivery => { if (filters.startDate) { const deliveryDate = new Date(delivery.scheduledDate); if (deliveryDate < filters.startDate) return false; } if (filters.endDate) { const deliveryDate = new Date(delivery.scheduledDate); if (deliveryDate > filters.endDate) return false; } if (filters.supplierIds?.length && !filters.supplierIds.includes(delivery.supplierId)) return false; if (filters.groupIds?.length && !filters.groupIds.includes(delivery.groupId)) return false; if (filters.status?.length && !filters.status.includes(delivery.status)) return false; return true; }); // Calculate metrics const totalOrders = filteredOrders.length; const totalDeliveries = filteredDeliveries.length; const reconciledDeliveries = filteredDeliveries.filter(d => d.reconciled === true || d.reconciled === 1 ).length; const reconciliationRate = totalDeliveries > 0 ? (reconciledDeliveries / totalDeliveries) * 100 : 0; const totalAmount = filteredDeliveries.reduce((sum, d) => { return sum + (parseFloat(d.blAmount as any) || 0) + (parseFloat(d.invoiceAmount as any) || 0); }, 0); // Top suppliers const supplierStats = new Map(); filteredDeliveries.forEach(d => { const supplier = this.suppliers.get(d.supplierId); if (supplier) { const stats = supplierStats.get(d.supplierId) || { name: supplier.name, count: 0, amount: 0 }; stats.count++; stats.amount += parseFloat(d.blAmount as any) || 0; supplierStats.set(d.supplierId, stats); } }); const topSuppliers = Array.from(supplierStats.entries()) .map(([id, stats]) => ({ id, ...stats })) .sort((a, b) => b.count - a.count) .slice(0, 5); // Top stores const storeStats = new Map(); filteredOrders.forEach(o => { const group = this.groups.get(o.groupId); if (group) { const stats = storeStats.get(o.groupId) || { name: group.name, orders: 0, deliveries: 0 }; stats.orders++; storeStats.set(o.groupId, stats); } }); filteredDeliveries.forEach(d => { const group = this.groups.get(d.groupId); if (group) { const stats = storeStats.get(d.groupId) || { name: group.name, orders: 0, deliveries: 0 }; stats.deliveries++; storeStats.set(d.groupId, stats); } }); const topStores = Array.from(storeStats.entries()) .map(([id, stats]) => ({ id, ...stats })) .sort((a, b) => (b.orders + b.deliveries) - (a.orders + a.deliveries)) .slice(0, 5); return { totalOrders, totalDeliveries, reconciliationRate, totalAmount, avgDeliveryDelay: 0, // Simplified for development topSuppliers, topStores }; } async getAnalyticsTimeseries(filters: { startDate?: Date; endDate?: Date; supplierIds?: number[]; groupIds?: number[]; granularity?: 'day' | 'week' | 'month'; }): Promise> { const dataMap = new Map(); // Process orders Array.from(this.orders.values()).forEach(order => { if (filters.startDate && order.plannedDate < filters.startDate) return; if (filters.endDate && order.plannedDate > filters.endDate) return; if (filters.supplierIds?.length && !filters.supplierIds.includes(order.supplierId)) return; if (filters.groupIds?.length && !filters.groupIds.includes(order.groupId)) return; const dateKey = order.plannedDate.toISOString().split('T')[0]; const existing = dataMap.get(dateKey) || { orders: 0, deliveries: 0 }; existing.orders++; dataMap.set(dateKey, existing); }); // Process deliveries Array.from(this.deliveries.values()).forEach(delivery => { if (filters.startDate && delivery.scheduledDate < filters.startDate) return; if (filters.endDate && delivery.scheduledDate > filters.endDate) return; if (filters.supplierIds?.length && !filters.supplierIds.includes(delivery.supplierId)) return; if (filters.groupIds?.length && !filters.groupIds.includes(delivery.groupId)) return; const dateKey = delivery.scheduledDate.toISOString().split('T')[0]; const existing = dataMap.get(dateKey) || { orders: 0, deliveries: 0 }; existing.deliveries++; dataMap.set(dateKey, existing); }); return Array.from(dataMap.entries()) .map(([date, data]) => ({ date, ...data })) .sort((a, b) => a.date.localeCompare(b.date)); } async getAnalyticsBySupplier(filters: { startDate?: Date; endDate?: Date; groupIds?: number[]; }): Promise> { const supplierStats = new Map(); Array.from(this.deliveries.values()).forEach(delivery => { if (filters.startDate && delivery.scheduledDate < filters.startDate) return; if (filters.endDate && delivery.scheduledDate > filters.endDate) return; if (filters.groupIds?.length && !filters.groupIds.includes(delivery.groupId)) return; const supplier = this.suppliers.get(delivery.supplierId); if (supplier) { const stats = supplierStats.get(delivery.supplierId) || { name: supplier.name, deliveries: 0, amount: 0 }; stats.deliveries++; stats.amount += parseFloat(delivery.blAmount as any) || 0; supplierStats.set(delivery.supplierId, stats); } }); return Array.from(supplierStats.entries()) .map(([id, stats]) => ({ supplierId: id, supplierName: stats.name, ...stats })) .sort((a, b) => b.deliveries - a.deliveries); } async getAnalyticsByStore(filters: { startDate?: Date; endDate?: Date; supplierIds?: number[]; }): Promise> { const storeStats = new Map(); // Count orders Array.from(this.orders.values()).forEach(order => { if (filters.startDate && order.plannedDate < filters.startDate) return; if (filters.endDate && order.plannedDate > filters.endDate) return; if (filters.supplierIds?.length && !filters.supplierIds.includes(order.supplierId)) return; const group = this.groups.get(order.groupId); if (group) { const stats = storeStats.get(order.groupId) || { name: group.name, orders: 0, deliveries: 0 }; stats.orders++; storeStats.set(order.groupId, stats); } }); // Count deliveries Array.from(this.deliveries.values()).forEach(delivery => { if (filters.startDate && delivery.scheduledDate < filters.startDate) return; if (filters.endDate && delivery.scheduledDate > filters.endDate) return; if (filters.supplierIds?.length && !filters.supplierIds.includes(delivery.supplierId)) return; const group = this.groups.get(delivery.groupId); if (group) { const stats = storeStats.get(delivery.groupId) || { name: group.name, orders: 0, deliveries: 0 }; stats.deliveries++; storeStats.set(delivery.groupId, stats); } }); return Array.from(storeStats.entries()) .map(([id, stats]) => ({ groupId: id, storeName: stats.name, ...stats })) .sort((a, b) => (b.orders + b.deliveries) - (a.orders + a.deliveries)); } } // Use DatabaseStorage when DATABASE_URL is available, MemStorage as fallback const isProduction = process.env.NODE_ENV === 'production'; const hasDatabase = !!process.env.DATABASE_URL; console.log('🔗 Database initialization:', { NODE_ENV: process.env.NODE_ENV, isProduction: isProduction, hasDbUrl: hasDatabase, dbHost: process.env.DATABASE_URL?.split('@')[1]?.split('/')[0] || 'unknown' }); export const storage: IStorage = hasDatabase ? new DatabaseStorage() : new MemStorage(); if (hasDatabase) { console.log('✅ Using DatabaseStorage (PostgreSQL database detected)'); } else { console.log('🔧 DEV: Using MemStorage (no database URL found)'); }