mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-12 01:37:40 +02:00
- Fichiers statiques servis avant la session (plus de requêtes SQL par asset), /assets en cache immuable 1 an, index.html en no-cache, et compression gzip/brotli des réponses (dépendance compression, externe dans le bundle esbuild du Dockerfile). - req.user (déjà chargé par deserializeUser) réutilisé dans les handlers au lieu de relire l'utilisateur et ses magasins à chaque appel ; GET /api/user ne refait plus de requête. - Listes : le magasin joint est réduit aux champs lus par l'interface (plus de logo base64 ni de configuration SMTP/NocoDB dans chaque ligne), plus aucune empreinte de mot de passe dans les créateurs/auteurs ni dans /api/users. - N+1 supprimés (/api/users, annonces, historique SAV, caches de vérification des factures), requêtes indépendantes en parallèle (stats, analytics, météo, getDelivery, getUserWithGroups), jointure multiplicative des statistiques par magasin corrigée. - Échéancier limité au magasin demandé ; filtre status sur GET /api/deliveries. - Index de performance créés en arrière-plan au démarrage (CREATE INDEX CONCURRENTLY, reliquats invalides purgés sans verrou exclusif). - La connexion n'attend plus la sauvegarde quotidienne ; purge du cache des factures active en production ; logs volumineux retirés des chemins chauds ; NODE_ENV fixé dans l'image. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MsDJjQrAggcJwbbBtKhgyb
5915 lines
204 KiB
TypeScript
5915 lines
204 KiB
TypeScript
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<Date | null>`${groups.createdAt}`,
|
||
updatedAt: sql<Date | null>`${groups.updatedAt}`,
|
||
};
|
||
|
||
|
||
export interface IStorage {
|
||
// User operations
|
||
getUser(id: string): Promise<User | undefined>;
|
||
getUserByEmail(email: string): Promise<User | undefined>;
|
||
getUserByUsername(username: string): Promise<User | undefined>;
|
||
upsertUser(user: UpsertUser): Promise<User>;
|
||
getUserWithGroups(id: string): Promise<UserWithGroups | undefined>;
|
||
// Tous les utilisateurs avec leurs magasins, sans le champ password
|
||
getUsersWithGroups(): Promise<UserWithGroups[]>;
|
||
getUsers(): Promise<User[]>;
|
||
createUser(user: UpsertUser): Promise<User>;
|
||
updateUser(id: string, user: Partial<UpsertUser>): Promise<User>;
|
||
deleteUser(id: string): Promise<void>;
|
||
|
||
// Group operations
|
||
getGroups(): Promise<Group[]>;
|
||
getGroup(id: number): Promise<Group | undefined>;
|
||
createGroup(group: InsertGroup): Promise<Group>;
|
||
updateGroup(id: number, group: Partial<InsertGroup>): Promise<Group>;
|
||
deleteGroup(id: number): Promise<void>;
|
||
|
||
// Supplier operations
|
||
getSuppliers(): Promise<Supplier[]>;
|
||
createSupplier(supplier: InsertSupplier): Promise<Supplier>;
|
||
updateSupplier(id: number, supplier: Partial<InsertSupplier>): Promise<Supplier>;
|
||
deleteSupplier(id: number): Promise<void>;
|
||
|
||
// Contact operations
|
||
getContacts(groupIds?: number[]): Promise<Contact[]>;
|
||
createContact(contact: InsertContact): Promise<Contact>;
|
||
updateContact(id: number, contact: Partial<InsertContact>): Promise<Contact>;
|
||
deleteContact(id: number): Promise<void>;
|
||
|
||
// Order operations
|
||
getOrders(groupIds?: number[]): Promise<OrderWithRelations[]>;
|
||
getOrdersByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise<OrderWithRelations[]>;
|
||
getOrder(id: number): Promise<OrderWithRelations | undefined>;
|
||
createOrder(order: InsertOrder): Promise<Order>;
|
||
updateOrder(id: number, order: Partial<InsertOrder>): Promise<Order>;
|
||
deleteOrder(id: number): Promise<void>;
|
||
|
||
// Delivery operations
|
||
getDeliveries(groupIds?: number[], options?: { status?: string }): Promise<DeliveryWithRelations[]>;
|
||
getDeliveriesByDateRange(startDate: string, endDate: string, groupIds?: number[]): Promise<DeliveryWithRelations[]>;
|
||
getDelivery(id: number): Promise<DeliveryWithRelations | undefined>;
|
||
createDelivery(delivery: InsertDelivery): Promise<Delivery>;
|
||
updateDelivery(id: number, delivery: Partial<InsertDelivery>): Promise<Delivery>;
|
||
deleteDelivery(id: number): Promise<void>;
|
||
validateDelivery(id: number, blData?: { blNumber: string; blAmount: number }): Promise<void>;
|
||
markDeliveryControlValidated(id: number, userId: string): Promise<void>;
|
||
|
||
// User-Group operations
|
||
getUserGroups(userId: string): Promise<UserGroup[]>;
|
||
assignUserToGroup(userGroup: InsertUserGroup): Promise<UserGroup>;
|
||
removeUserFromGroup(userId: string, groupId: number): Promise<void>;
|
||
removeUserFromAllGroups(userId: string): Promise<void>;
|
||
|
||
// 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<PublicityWithRelations[]>;
|
||
getPublicity(id: number): Promise<PublicityWithRelations | undefined>;
|
||
createPublicity(publicity: InsertPublicity): Promise<Publicity>;
|
||
updatePublicity(id: number, publicity: Partial<InsertPublicity>): Promise<Publicity>;
|
||
deletePublicity(id: number): Promise<void>;
|
||
|
||
// Publicity participation operations
|
||
getPublicityParticipations(publicityId: number): Promise<PublicityParticipation[]>;
|
||
setPublicityParticipations(publicityId: number, groupIds: number[]): Promise<void>;
|
||
|
||
// NocoDB Configuration operations
|
||
getNocodbConfigs(): Promise<NocodbConfig[]>;
|
||
getNocodbConfig(id: number): Promise<NocodbConfig | undefined>;
|
||
getActiveNocodbConfig(): Promise<NocodbConfig | undefined>;
|
||
createNocodbConfig(config: InsertNocodbConfig): Promise<NocodbConfig>;
|
||
updateNocodbConfig(id: number, config: Partial<InsertNocodbConfig>): Promise<NocodbConfig>;
|
||
deleteNocodbConfig(id: number): Promise<void>;
|
||
|
||
// Invoice Verification Cache operations
|
||
getInvoiceVerificationCache(cacheKey: string): Promise<InvoiceVerificationCache | undefined>;
|
||
saveInvoiceVerificationCache(cache: InsertInvoiceVerificationCache): Promise<InvoiceVerificationCache>;
|
||
createInvoiceVerificationCache(cache: InsertInvoiceVerificationCache): Promise<InvoiceVerificationCache>;
|
||
clearExpiredCache(): Promise<void>;
|
||
|
||
// Statistiques agrégées (comptages calculés en base, sans rapatrier les lignes)
|
||
getOrderDeliveryStatsByGroup(groupIds?: number[]): Promise<EntityActivityStats[]>;
|
||
getOrderDeliveryStatsBySupplier(groupIds?: number[]): Promise<EntityActivityStats[]>;
|
||
|
||
// Customer Order operations
|
||
getCustomerOrders(groupIds?: number[]): Promise<CustomerOrderWithRelations[]>;
|
||
getCustomerOrder(id: number): Promise<CustomerOrderWithRelations | undefined>;
|
||
createCustomerOrder(customerOrder: InsertCustomerOrder): Promise<CustomerOrder>;
|
||
updateCustomerOrder(id: number, customerOrder: Partial<InsertCustomerOrder>): Promise<CustomerOrder>;
|
||
deleteCustomerOrder(id: number): Promise<void>;
|
||
|
||
// Client call tracking
|
||
getPendingClientCalls(groupIds?: number[]): Promise<CustomerOrderWithRelations[]>;
|
||
markClientCalled(customerOrderId: number, calledBy: string, comment?: string): Promise<CustomerOrder>;
|
||
|
||
// DLC Product operations
|
||
getDlcProducts(groupIds?: number[], filters?: { status?: string; supplierId?: number; search?: string; }): Promise<DlcProductWithRelations[]>;
|
||
getDlcProduct(id: number): Promise<DlcProductWithRelations | undefined>;
|
||
createDlcProduct(dlcProduct: InsertDlcProduct): Promise<DlcProductFrontend>;
|
||
updateDlcProduct(id: number, dlcProduct: Partial<InsertDlcProduct>): Promise<DlcProductFrontend>;
|
||
deleteDlcProduct(id: number): Promise<void>;
|
||
validateDlcProduct(id: number, validatedBy: string): Promise<DlcProductFrontend>;
|
||
markDlcProductStockEpuise(id: number, markedBy: string): Promise<DlcProductFrontend>;
|
||
restoreDlcProductStock(id: number): Promise<DlcProductFrontend>;
|
||
markDlcProductAsProcessed(id: number, processedBy: string): Promise<DlcProductFrontend>;
|
||
unmarkDlcProductAsProcessed(id: number): Promise<DlcProductFrontend>;
|
||
getDlcStats(groupIds?: number[]): Promise<{ active: number; expiringSoon: number; expired: number; }>;
|
||
|
||
// Task operations
|
||
getTasks(groupIds?: number[], userRole?: string): Promise<any[]>;
|
||
getTask(id: number): Promise<any | undefined>;
|
||
createTask(task: InsertTask): Promise<Task>;
|
||
updateTask(id: number, task: Partial<InsertTask>): Promise<Task>;
|
||
deleteTask(id: number): Promise<void>;
|
||
completeTask(id: number, completedBy?: string): Promise<void>;
|
||
|
||
// Announcement operations
|
||
createAnnouncement(announcement: InsertAnnouncement): Promise<Announcement>;
|
||
getAnnouncements(groupIds?: number[]): Promise<AnnouncementWithRelations[]>;
|
||
getAnnouncement(id: number): Promise<AnnouncementWithRelations | null | undefined>;
|
||
updateAnnouncement(id: number, announcement: Partial<InsertAnnouncement>): Promise<DashboardMessage>;
|
||
deleteAnnouncement(id: number): Promise<boolean>;
|
||
|
||
// Avoir operations
|
||
getAvoirs(groupIds?: number[]): Promise<AvoirWithRelations[]>;
|
||
getAvoir(id: number): Promise<AvoirWithRelations | undefined>;
|
||
createAvoir(avoir: InsertAvoir): Promise<Avoir>;
|
||
updateAvoir(id: number, avoir: Partial<InsertAvoir>): Promise<Avoir>;
|
||
deleteAvoir(id: number): Promise<void>;
|
||
updateAvoirWebhookStatus(id: number, webhookSent: boolean): Promise<void>;
|
||
updateAvoirNocodbVerification(id: number, verified: boolean): Promise<void>;
|
||
|
||
// SAV operations
|
||
getSavTickets(filters?: {
|
||
groupIds?: number[];
|
||
status?: string;
|
||
supplierId?: number;
|
||
priority?: string;
|
||
startDate?: string;
|
||
endDate?: string;
|
||
}): Promise<SavTicketWithRelations[]>;
|
||
getSavTicket(id: number): Promise<SavTicketWithRelations | undefined>;
|
||
createSavTicket(ticket: InsertSavTicket): Promise<SavTicket>;
|
||
updateSavTicket(id: number, ticket: Partial<InsertSavTicket>): Promise<SavTicket>;
|
||
deleteSavTicket(id: number): Promise<void>;
|
||
getSavTicketHistory(ticketId: number): Promise<SavTicketHistoryWithCreator[]>;
|
||
addSavTicketHistory(history: InsertSavTicketHistory): Promise<SavTicketHistory>;
|
||
getSavTicketStats(groupIds?: number[]): Promise<{
|
||
totalTickets: number;
|
||
newTickets: number;
|
||
inProgressTickets: number;
|
||
resolvedTickets: number;
|
||
criticalTickets: number;
|
||
}>;
|
||
|
||
// Weather operations
|
||
getWeatherSettings(): Promise<WeatherSettings | undefined>;
|
||
createWeatherSettings(settings: InsertWeatherSettings): Promise<WeatherSettings>;
|
||
updateWeatherSettings(id: number, settings: Partial<InsertWeatherSettings>): Promise<WeatherSettings>;
|
||
getWeatherData(date: string, isCurrentYear: boolean): Promise<WeatherData | undefined>;
|
||
createWeatherData(data: InsertWeatherData): Promise<WeatherData>;
|
||
updateWeatherData(id: number, data: Partial<InsertWeatherData>): Promise<WeatherData>;
|
||
deleteOldWeatherData(daysToKeep: number): Promise<void>;
|
||
getNearestWeatherData(date: string, isCurrentYear: boolean, maxDistanceDays: number): Promise<WeatherData | undefined>;
|
||
clearWeatherCache(): Promise<void>;
|
||
|
||
// Historique des mails fournisseurs (rapprochement)
|
||
createSupplierMailLog(log: InsertSupplierMailLog): Promise<SupplierMailLog>;
|
||
getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise<SupplierMailLog[]>;
|
||
|
||
// Webhook BAP Configuration
|
||
getWebhookBapConfig(): Promise<WebhookBapConfig | undefined>;
|
||
createWebhookBapConfig(config: InsertWebhookBapConfig): Promise<WebhookBapConfig>;
|
||
updateWebhookBapConfig(id: number, config: Partial<InsertWebhookBapConfig>): Promise<WebhookBapConfig>;
|
||
|
||
// Utilities operations
|
||
getUtilities(): Promise<Utilities | undefined>;
|
||
createUtilities(utilities: InsertUtilities): Promise<Utilities>;
|
||
updateUtilities(id: number, utilities: Partial<InsertUtilities>): Promise<Utilities>;
|
||
|
||
// Reconciliation Comments operations
|
||
getReconciliationComments(deliveryId: number): Promise<ReconciliationCommentWithRelations[]>;
|
||
getReconciliationCommentById(id: number): Promise<ReconciliationCommentWithRelations | undefined>;
|
||
createReconciliationComment(comment: InsertReconciliationComment): Promise<ReconciliationComment>;
|
||
updateReconciliationComment(id: number, comment: Partial<InsertReconciliationComment>): Promise<ReconciliationComment>;
|
||
deleteReconciliationComment(id: number): Promise<void>;
|
||
|
||
// 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<Array<{ date: string; orders: number; deliveries: number }>>;
|
||
|
||
getAnalyticsBySupplier(filters: {
|
||
startDate?: Date;
|
||
endDate?: Date;
|
||
groupIds?: number[];
|
||
}): Promise<Array<{ supplierId: number; supplierName: string; deliveries: number; amount: number }>>;
|
||
|
||
getAnalyticsByStore(filters: {
|
||
startDate?: Date;
|
||
endDate?: Date;
|
||
supplierIds?: number[];
|
||
}): Promise<Array<{ groupId: number; storeName: string; orders: number; deliveries: number }>>;
|
||
}
|
||
|
||
export class DatabaseStorage implements IStorage {
|
||
// User operations
|
||
async getUser(id: string): Promise<User | undefined> {
|
||
const [user] = await db.select().from(users).where(eq(users.id, id));
|
||
return user;
|
||
}
|
||
|
||
async getUserByEmail(email: string): Promise<User | undefined> {
|
||
const [user] = await db.select().from(users).where(eq(users.email, email));
|
||
return user;
|
||
}
|
||
|
||
async getUserByUsername(username: string): Promise<User | undefined> {
|
||
const [user] = await db.select().from(users).where(eq(users.username, username));
|
||
return user;
|
||
}
|
||
|
||
async upsertUser(userData: UpsertUser): Promise<User> {
|
||
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<UserWithGroups | undefined> {
|
||
// 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<UserWithGroups[]> {
|
||
// 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<string, any[]>();
|
||
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<User[]> {
|
||
return await db.select().from(users);
|
||
}
|
||
|
||
async createUser(userData: UpsertUser): Promise<User> {
|
||
const [user] = await db.insert(users).values(userData).returning();
|
||
return user;
|
||
}
|
||
|
||
async updateUser(id: string, userData: Partial<UpsertUser>): Promise<User> {
|
||
const [user] = await db
|
||
.update(users)
|
||
.set({ ...userData, updatedAt: new Date() })
|
||
.where(eq(users.id, id))
|
||
.returning();
|
||
return user;
|
||
}
|
||
|
||
async deleteUser(id: string): Promise<void> {
|
||
await db.delete(users).where(eq(users.id, id));
|
||
}
|
||
|
||
// Group operations
|
||
async getGroups(): Promise<Group[]> {
|
||
return await db.select().from(groups).orderBy(groups.name);
|
||
}
|
||
|
||
async getGroup(id: number): Promise<Group | undefined> {
|
||
const [group] = await db.select().from(groups).where(eq(groups.id, id));
|
||
return group;
|
||
}
|
||
|
||
async createGroup(groupData: InsertGroup): Promise<Group> {
|
||
// 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<InsertGroup>): Promise<Group> {
|
||
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<void> {
|
||
await db.delete(groups).where(eq(groups.id, id));
|
||
}
|
||
|
||
// Supplier operations
|
||
async getSuppliers(): Promise<Supplier[]> {
|
||
return await db.select().from(suppliers).orderBy(suppliers.name);
|
||
}
|
||
|
||
async createSupplier(supplierData: InsertSupplier): Promise<Supplier> {
|
||
const [supplier] = await db.insert(suppliers).values(supplierData).returning();
|
||
return supplier;
|
||
}
|
||
|
||
async updateSupplier(id: number, supplierData: Partial<InsertSupplier>): Promise<Supplier> {
|
||
const [supplier] = await db
|
||
.update(suppliers)
|
||
.set({ ...supplierData, updatedAt: new Date() })
|
||
.where(eq(suppliers.id, id))
|
||
.returning();
|
||
return supplier;
|
||
}
|
||
|
||
async deleteSupplier(id: number): Promise<void> {
|
||
await db.delete(suppliers).where(eq(suppliers.id, id));
|
||
}
|
||
|
||
// Contact operations
|
||
async getContacts(groupIds?: number[]): Promise<Contact[]> {
|
||
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<Contact> {
|
||
const [contact] = await db.insert(contacts).values(contactData).returning();
|
||
return contact;
|
||
}
|
||
|
||
async updateContact(id: number, contactData: Partial<InsertContact>): Promise<Contact> {
|
||
const [contact] = await db
|
||
.update(contacts)
|
||
.set({ ...contactData, updatedAt: new Date() })
|
||
.where(eq(contacts.id, id))
|
||
.returning();
|
||
return contact;
|
||
}
|
||
|
||
async deleteContact(id: number): Promise<void> {
|
||
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<Map<number, any[]>> {
|
||
const byOrderId = new Map<number, any[]>();
|
||
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<Map<number, any>> {
|
||
const byId = new Map<number, any>();
|
||
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<Map<number, number>> {
|
||
const counts = new Map<number, number>();
|
||
if (deliveryIds.length === 0) return counts;
|
||
|
||
const rows = await db
|
||
.select({
|
||
deliveryId: reconciliationComments.deliveryId,
|
||
count: sql<number>`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<any[]> {
|
||
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<OrderWithRelations[]> {
|
||
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<OrderWithRelations[]> {
|
||
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<OrderWithRelations | undefined> {
|
||
// 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<Order> {
|
||
const [order] = await db.insert(orders).values(orderData).returning();
|
||
return order;
|
||
}
|
||
|
||
async updateOrder(id: number, orderData: Partial<InsertOrder>): Promise<Order> {
|
||
const [order] = await db
|
||
.update(orders)
|
||
.set({ ...orderData, updatedAt: new Date() })
|
||
.where(eq(orders.id, id))
|
||
.returning();
|
||
return order;
|
||
}
|
||
|
||
async deleteOrder(id: number): Promise<void> {
|
||
await db.delete(orders).where(eq(orders.id, id));
|
||
}
|
||
|
||
// Delivery operations
|
||
async getDeliveries(groupIds?: number[], options?: { status?: string }): Promise<DeliveryWithRelations[]> {
|
||
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<DeliveryWithRelations[]> {
|
||
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<DeliveryWithRelations | undefined> {
|
||
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<number>`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<Delivery> {
|
||
// 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<InsertDelivery>): Promise<Delivery> {
|
||
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<void> {
|
||
await db.delete(deliveries).where(eq(deliveries.id, id));
|
||
}
|
||
|
||
async validateDelivery(id: number, blData?: { blNumber: string; blAmount: number }): Promise<void> {
|
||
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<void> {
|
||
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<UserGroup[]> {
|
||
return await db.select().from(userGroups).where(eq(userGroups.userId, userId));
|
||
}
|
||
|
||
async assignUserToGroup(userGroupData: InsertUserGroup): Promise<UserGroup> {
|
||
const [userGroup] = await db.insert(userGroups).values(userGroupData).returning();
|
||
return userGroup;
|
||
}
|
||
|
||
async removeUserFromGroup(userId: string, groupId: number): Promise<void> {
|
||
await db.delete(userGroups).where(
|
||
and(eq(userGroups.userId, userId), eq(userGroups.groupId, groupId))
|
||
);
|
||
}
|
||
|
||
async removeUserFromAllGroups(userId: string): Promise<void> {
|
||
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<number>`count(*)` })
|
||
.from(orders)
|
||
.where(ordersWhereCondition);
|
||
|
||
// Compter les livraisons du mois
|
||
const deliveriesQuery = db
|
||
.select({ count: sql<number>`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<number>`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<number>`COALESCE(SUM(CAST(${deliveries.quantity} as INTEGER)), 0)`,
|
||
totalPackages: sql<number>`COALESCE(COUNT(*), 0)`,
|
||
avgDelay: sql<number>`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<number>`count(*)` })
|
||
.from(orders)
|
||
.where(ordersWhereCondition);
|
||
|
||
// Compter les livraisons de l'année
|
||
const deliveriesQuery = db
|
||
.select({ count: sql<number>`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<number>`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<number>`COALESCE(SUM(CAST(${deliveries.quantity} as INTEGER)), 0)`,
|
||
totalPackages: sql<number>`COALESCE(COUNT(*), 0)`,
|
||
avgDelay: sql<number>`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<PublicityWithRelations[]> {
|
||
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<number, any[]>();
|
||
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<PublicityWithRelations | undefined> {
|
||
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<Publicity> {
|
||
const [publicity] = await db.insert(publicities).values(publicityData).returning();
|
||
return publicity;
|
||
}
|
||
|
||
async updatePublicity(id: number, publicityData: Partial<InsertPublicity>): Promise<Publicity> {
|
||
const [publicity] = await db
|
||
.update(publicities)
|
||
.set({ ...publicityData, updatedAt: new Date() })
|
||
.where(eq(publicities.id, id))
|
||
.returning();
|
||
return publicity;
|
||
}
|
||
|
||
async deletePublicity(id: number): Promise<void> {
|
||
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<PublicityParticipation[]> {
|
||
return await db
|
||
.select()
|
||
.from(publicityParticipations)
|
||
.where(eq(publicityParticipations.publicityId, publicityId));
|
||
}
|
||
|
||
async setPublicityParticipations(publicityId: number, groupIds: number[]): Promise<void> {
|
||
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<T extends NocodbConfig | undefined>(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<NocodbConfig[]> {
|
||
const configs = await db.select().from(nocodbConfig).orderBy(desc(nocodbConfig.createdAt));
|
||
return configs.map((c: NocodbConfig) => this.decryptNocodbConfig(c));
|
||
}
|
||
|
||
async getNocodbConfig(id: number): Promise<NocodbConfig | undefined> {
|
||
const [config] = await db.select().from(nocodbConfig).where(eq(nocodbConfig.id, id));
|
||
return this.decryptNocodbConfig(config);
|
||
}
|
||
|
||
async getActiveNocodbConfig(): Promise<NocodbConfig | undefined> {
|
||
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<NocodbConfig> {
|
||
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<InsertNocodbConfig>): Promise<NocodbConfig> {
|
||
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<void> {
|
||
await db.delete(nocodbConfig).where(eq(nocodbConfig.id, id));
|
||
}
|
||
|
||
// Invoice Verification Cache operations
|
||
async getInvoiceVerificationCache(cacheKey: string): Promise<InvoiceVerificationCache | undefined> {
|
||
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<InvoiceVerificationCache> {
|
||
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<InvoiceVerificationCache> {
|
||
return this.createInvoiceVerificationCache(cacheData);
|
||
}
|
||
|
||
async clearExpiredCache(): Promise<void> {
|
||
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<number, EntityActivityStats>();
|
||
|
||
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<EntityActivityStats[]> {
|
||
const scoped = groupIds && groupIds.length > 0;
|
||
|
||
const orderQuery = db
|
||
.select({ id: orders.groupId, count: sql<number>`count(*)` })
|
||
.from(orders)
|
||
.groupBy(orders.groupId);
|
||
|
||
const deliveryQuery = db
|
||
.select({
|
||
id: deliveries.groupId,
|
||
count: sql<number>`count(*)`,
|
||
delivered: sql<number>`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<EntityActivityStats[]> {
|
||
const scoped = groupIds && groupIds.length > 0;
|
||
|
||
const orderQuery = db
|
||
.select({ id: orders.supplierId, count: sql<number>`count(*)` })
|
||
.from(orders)
|
||
.groupBy(orders.supplierId);
|
||
|
||
const deliveryQuery = db
|
||
.select({
|
||
id: deliveries.supplierId,
|
||
count: sql<number>`count(*)`,
|
||
delivered: sql<number>`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<CustomerOrderWithRelations[]> {
|
||
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<CustomerOrderWithRelations | undefined> {
|
||
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<CustomerOrder> {
|
||
const [customerOrder] = await db.insert(customerOrders).values(orderData).returning();
|
||
return customerOrder;
|
||
}
|
||
|
||
async updateCustomerOrder(id: number, orderData: Partial<InsertCustomerOrder>): Promise<CustomerOrder> {
|
||
const [customerOrder] = await db
|
||
.update(customerOrders)
|
||
.set({ ...orderData, updatedAt: new Date() })
|
||
.where(eq(customerOrders.id, id))
|
||
.returning();
|
||
return customerOrder;
|
||
}
|
||
|
||
async deleteCustomerOrder(id: number): Promise<void> {
|
||
await db.delete(customerOrders).where(eq(customerOrders.id, id));
|
||
}
|
||
|
||
async getPendingClientCalls(groupIds?: number[]): Promise<CustomerOrderWithRelations[]> {
|
||
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<CustomerOrder> {
|
||
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<DlcProductWithRelations[]> {
|
||
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<DlcProductWithRelations | undefined> {
|
||
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<DlcProductFrontend> {
|
||
const [dlcProduct] = await db.insert(dlcProducts).values(productData).returning();
|
||
return { ...dlcProduct, dlcDate: new Date(dlcProduct.expiryDate) } as DlcProductFrontend;
|
||
}
|
||
|
||
async updateDlcProduct(id: number, productData: Partial<InsertDlcProduct>): Promise<DlcProductFrontend> {
|
||
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<void> {
|
||
await db.delete(dlcProducts).where(eq(dlcProducts.id, id));
|
||
}
|
||
|
||
async validateDlcProduct(id: number, validatedBy: string): Promise<DlcProductFrontend> {
|
||
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<DlcProductFrontend> {
|
||
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<DlcProductFrontend> {
|
||
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<DlcProductFrontend> {
|
||
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<DlcProductFrontend> {
|
||
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<number>`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<number>`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<number>`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<TaskWithRelations[]> {
|
||
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<TaskWithRelations | undefined> {
|
||
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<Task> {
|
||
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<InsertTask>): Promise<Task> {
|
||
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<void> {
|
||
await db.delete(tasks).where(eq(tasks.id, id));
|
||
}
|
||
|
||
async completeTask(id: number, completedBy: string): Promise<void> {
|
||
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<Announcement> {
|
||
const announcementStorage = getAnnouncementStorage(
|
||
() => this.getUsers(),
|
||
() => this.getGroups()
|
||
);
|
||
return await announcementStorage.createAnnouncement(announcementData);
|
||
}
|
||
|
||
async getAnnouncements(groupIds?: number[]): Promise<AnnouncementWithRelations[]> {
|
||
const announcementStorage = getAnnouncementStorage(
|
||
() => this.getUsers(),
|
||
() => this.getGroups()
|
||
);
|
||
return await announcementStorage.getAnnouncements(groupIds);
|
||
}
|
||
|
||
async getAnnouncement(id: number): Promise<AnnouncementWithRelations | null | undefined> {
|
||
const announcementStorage = getAnnouncementStorage(
|
||
() => this.getUsers(),
|
||
() => this.getGroups()
|
||
);
|
||
return await announcementStorage.getAnnouncement(id);
|
||
}
|
||
|
||
async updateAnnouncement(id: number, announcementData: Partial<InsertAnnouncement>): Promise<DashboardMessage> {
|
||
const announcementStorage = getAnnouncementStorage(
|
||
() => this.getUsers(),
|
||
() => this.getGroups()
|
||
);
|
||
return await announcementStorage.updateAnnouncement(id, announcementData);
|
||
}
|
||
|
||
async deleteAnnouncement(id: number): Promise<boolean> {
|
||
const announcementStorage = getAnnouncementStorage(
|
||
() => this.getUsers(),
|
||
() => this.getGroups()
|
||
);
|
||
return await announcementStorage.deleteAnnouncement(id);
|
||
}
|
||
|
||
// Avoir operations
|
||
async getAvoirs(groupIds?: number[]): Promise<AvoirWithRelations[]> {
|
||
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<AvoirWithRelations | undefined> {
|
||
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<Avoir> {
|
||
const [avoir] = await db.insert(avoirs).values(avoirData).returning();
|
||
return avoir;
|
||
}
|
||
|
||
async updateAvoir(id: number, avoirData: Partial<InsertAvoir>): Promise<Avoir> {
|
||
const [avoir] = await db
|
||
.update(avoirs)
|
||
.set({ ...avoirData, updatedAt: new Date() })
|
||
.where(eq(avoirs.id, id))
|
||
.returning();
|
||
return avoir;
|
||
}
|
||
|
||
async deleteAvoir(id: number): Promise<void> {
|
||
await db.delete(avoirs).where(eq(avoirs.id, id));
|
||
}
|
||
|
||
async updateAvoirWebhookStatus(id: number, webhookSent: boolean): Promise<void> {
|
||
await db
|
||
.update(avoirs)
|
||
.set({ webhookSent, updatedAt: new Date() })
|
||
.where(eq(avoirs.id, id));
|
||
}
|
||
|
||
async updateAvoirNocodbVerification(id: number, verified: boolean): Promise<void> {
|
||
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<SavTicketWithRelations[]> {
|
||
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<SavTicketWithRelations | undefined> {
|
||
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<SavTicket> {
|
||
// 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<number>`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<InsertSavTicket>): Promise<SavTicket> {
|
||
const [ticket] = await db
|
||
.update(savTickets)
|
||
.set({ ...ticketData, updatedAt: new Date() })
|
||
.where(eq(savTickets.id, id))
|
||
.returning();
|
||
|
||
return ticket;
|
||
}
|
||
|
||
async deleteSavTicket(id: number): Promise<void> {
|
||
// 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<SavTicketHistoryWithCreator[]> {
|
||
// 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<any> {
|
||
// 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<number>`count(*)`,
|
||
newCount: sql<number>`count(*) filter (where ${savTickets.status} = 'nouveau')`,
|
||
inProgressCount: sql<number>`count(*) filter (where ${savTickets.status} in ('en_cours', 'attente_pieces', 'attente_echange'))`,
|
||
resolvedCount: sql<number>`count(*) filter (where ${savTickets.status} in ('resolu', 'ferme'))`,
|
||
criticalCount: sql<number>`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<WeatherSettings | undefined> {
|
||
const [settings] = await db.select().from(weatherSettings).limit(1);
|
||
return settings;
|
||
}
|
||
|
||
async createWeatherSettings(settingsData: InsertWeatherSettings): Promise<WeatherSettings> {
|
||
const [settings] = await db.insert(weatherSettings).values(settingsData).returning();
|
||
return settings;
|
||
}
|
||
|
||
async updateWeatherSettings(id: number, settingsData: Partial<InsertWeatherSettings>): Promise<WeatherSettings> {
|
||
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<WeatherData | undefined> {
|
||
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<WeatherData> {
|
||
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<WeatherData | undefined> {
|
||
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<InsertWeatherData>): Promise<WeatherData> {
|
||
const [weatherInfo] = await db
|
||
.update(weatherData)
|
||
.set({ ...data, updatedAt: new Date() })
|
||
.where(eq(weatherData.id, id))
|
||
.returning();
|
||
return weatherInfo;
|
||
}
|
||
|
||
async deleteOldWeatherData(daysToKeep: number): Promise<void> {
|
||
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<void> {
|
||
await db.delete(weatherData);
|
||
console.log('🧹 Weather cache cleared due to location change');
|
||
}
|
||
|
||
// Historique des mails fournisseurs (rapprochement)
|
||
async createSupplierMailLog(logData: InsertSupplierMailLog): Promise<SupplierMailLog> {
|
||
const [log] = await db.insert(supplierMailLogs).values(logData).returning();
|
||
return log;
|
||
}
|
||
|
||
async getSupplierMailLogs(groupIds?: number[], deliveryId?: number): Promise<SupplierMailLog[]> {
|
||
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<WebhookBapConfig | undefined> {
|
||
const [config] = await db.select().from(webhookBapConfig).limit(1);
|
||
return config;
|
||
}
|
||
|
||
async createWebhookBapConfig(configData: InsertWebhookBapConfig): Promise<WebhookBapConfig> {
|
||
const [config] = await db
|
||
.insert(webhookBapConfig)
|
||
.values(configData)
|
||
.returning();
|
||
return config;
|
||
}
|
||
|
||
async updateWebhookBapConfig(id: number, configData: Partial<InsertWebhookBapConfig>): Promise<WebhookBapConfig> {
|
||
const [config] = await db
|
||
.update(webhookBapConfig)
|
||
.set({ ...configData, updatedAt: new Date() })
|
||
.where(eq(webhookBapConfig.id, id))
|
||
.returning();
|
||
return config;
|
||
}
|
||
|
||
// Utilities operations
|
||
async getUtilities(): Promise<Utilities | undefined> {
|
||
const [result] = await db.select().from(utilities).limit(1);
|
||
return result;
|
||
}
|
||
|
||
async createUtilities(utilitiesData: InsertUtilities): Promise<Utilities> {
|
||
const [result] = await db.insert(utilities).values(utilitiesData).returning();
|
||
return result;
|
||
}
|
||
|
||
async updateUtilities(id: number, utilitiesData: Partial<InsertUtilities>): Promise<Utilities> {
|
||
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<ReconciliationCommentWithRelations[]> {
|
||
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<ReconciliationCommentWithRelations | undefined> {
|
||
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<ReconciliationComment> {
|
||
const [comment] = await db
|
||
.insert(reconciliationComments)
|
||
.values(commentData)
|
||
.returning();
|
||
return comment;
|
||
}
|
||
|
||
async updateReconciliationComment(id: number, commentData: Partial<InsertReconciliationComment>): Promise<ReconciliationComment> {
|
||
const [comment] = await db
|
||
.update(reconciliationComments)
|
||
.set({ ...commentData, updatedAt: new Date() })
|
||
.where(eq(reconciliationComments.id, id))
|
||
.returning();
|
||
return comment;
|
||
}
|
||
|
||
async deleteReconciliationComment(id: number): Promise<void> {
|
||
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<number>`COUNT(*)` }).from(orders);
|
||
if (orderConditions.length) orderQuery.where(and(...orderConditions));
|
||
|
||
// Get total deliveries and amounts
|
||
const deliveryQuery = db.select({
|
||
count: sql<number>`COUNT(*)`,
|
||
totalAmount: sql<number>`COALESCE(SUM(CAST(bl_amount AS NUMERIC)), 0) + COALESCE(SUM(CAST(invoice_amount AS NUMERIC)), 0)`,
|
||
reconciled: sql<number>`COUNT(CASE WHEN reconciled = true THEN 1 END)`,
|
||
avgDelay: sql<number>`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<number>`COUNT(${deliveries.id})`,
|
||
amount: sql<number>`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<number>`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<Array<{ date: string; orders: number; deliveries: number }>> {
|
||
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<string, { orders: number; deliveries: number }>();
|
||
(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<Array<{ supplierId: number; supplierName: string; deliveries: number; amount: number }>> {
|
||
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<number>`COUNT(${deliveries.id})`,
|
||
amount: sql<number>`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<number>`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<Array<{ groupId: number; storeName: string; orders: number; deliveries: number }>> {
|
||
|
||
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<string, User>();
|
||
private groups = new Map<number, Group>();
|
||
private suppliers = new Map<number, Supplier>();
|
||
private orders = new Map<number, Order>();
|
||
private deliveries = new Map<number, Delivery>();
|
||
private userGroups = new Map<string, UserGroup[]>();
|
||
private publicities = new Map<number, Publicity>();
|
||
private publicityParticipations = new Map<number, PublicityParticipation[]>();
|
||
private customerOrders = new Map<number, CustomerOrder>();
|
||
private dlcProducts = new Map<number, DlcProduct>();
|
||
private tasks = new Map<number, Task>();
|
||
private reconciliationComments = new Map<number, ReconciliationComment>();
|
||
private announcements = new Map<number, Announcement>();
|
||
private avoirs = new Map<number, Avoir>();
|
||
private savTickets = new Map<number, SavTicket>();
|
||
private invoiceVerificationCache = new Map<string, InvoiceVerificationCache>();
|
||
private contactsMap = new Map<number, Contact>();
|
||
|
||
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<User | undefined> {
|
||
return this.users.get(id);
|
||
}
|
||
|
||
async getUserByEmail(email: string): Promise<User | undefined> {
|
||
for (const user of this.users.values()) {
|
||
if (user.email === email) return user;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
async getUserByUsername(username: string): Promise<User | undefined> {
|
||
for (const user of this.users.values()) {
|
||
if (user.username === username) return user;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
async upsertUser(userData: UpsertUser): Promise<User> {
|
||
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<UserWithGroups | undefined> {
|
||
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<UserWithGroups[]> {
|
||
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<User[]> {
|
||
return Array.from(this.users.values());
|
||
}
|
||
|
||
async createUser(userData: UpsertUser): Promise<User> {
|
||
return this.upsertUser(userData);
|
||
}
|
||
|
||
async updateUser(id: string, userData: Partial<UpsertUser>): Promise<User> {
|
||
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<void> {
|
||
this.users.delete(id);
|
||
this.userGroups.delete(id);
|
||
}
|
||
|
||
// Group operations
|
||
async getGroups(): Promise<Group[]> {
|
||
return Array.from(this.groups.values());
|
||
}
|
||
|
||
async getGroup(id: number): Promise<Group | undefined> {
|
||
return this.groups.get(id);
|
||
}
|
||
|
||
async createGroup(groupData: InsertGroup): Promise<Group> {
|
||
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<InsertGroup>): Promise<Group> {
|
||
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<void> {
|
||
this.groups.delete(id);
|
||
}
|
||
|
||
// Supplier operations
|
||
async getSuppliers(): Promise<Supplier[]> {
|
||
return Array.from(this.suppliers.values());
|
||
}
|
||
|
||
async createSupplier(supplierData: InsertSupplier): Promise<Supplier> {
|
||
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<InsertSupplier>): Promise<Supplier> {
|
||
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<void> {
|
||
this.suppliers.delete(id);
|
||
}
|
||
|
||
// Contact operations
|
||
async getContacts(groupIds?: number[]): Promise<Contact[]> {
|
||
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<Contact> {
|
||
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<InsertContact>): Promise<Contact> {
|
||
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<void> {
|
||
this.contactsMap.delete(id);
|
||
}
|
||
|
||
// NocoDB Configuration methods
|
||
async getNocodbConfigs(): Promise<NocodbConfig[]> {
|
||
return []; // Empty array for development
|
||
}
|
||
|
||
async getNocodbConfig(id: number): Promise<NocodbConfig | undefined> {
|
||
return undefined; // Not found in development
|
||
}
|
||
|
||
async getActiveNocodbConfig(): Promise<NocodbConfig | undefined> {
|
||
// 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<NocodbConfig> {
|
||
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<InsertNocodbConfig>): Promise<NocodbConfig> {
|
||
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<void> {
|
||
// No-op in development
|
||
}
|
||
|
||
async getInvoiceVerificationCache(cacheKey: string): Promise<InvoiceVerificationCache | undefined> {
|
||
// 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<InvoiceVerificationCache> {
|
||
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<InvoiceVerificationCache> {
|
||
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<void> {
|
||
// No-op en développement
|
||
}
|
||
|
||
private activityStatsBy(
|
||
key: 'groupId' | 'supplierId',
|
||
groupIds?: number[]
|
||
): EntityActivityStats[] {
|
||
const byId = new Map<number, EntityActivityStats>();
|
||
|
||
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<EntityActivityStats[]> {
|
||
return this.activityStatsBy('groupId', groupIds);
|
||
}
|
||
|
||
async getOrderDeliveryStatsBySupplier(groupIds?: number[]): Promise<EntityActivityStats[]> {
|
||
return this.activityStatsBy('supplierId', groupIds);
|
||
}
|
||
|
||
// MemStorage implementations with actual data handling
|
||
async getOrders(groupIds?: number[]): Promise<OrderWithRelations[]> {
|
||
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<OrderWithRelations[]> {
|
||
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<OrderWithRelations | undefined> {
|
||
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<Order> {
|
||
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<InsertOrder>): Promise<Order> {
|
||
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<void> {
|
||
this.orders.delete(id);
|
||
}
|
||
|
||
async getDeliveries(groupIds?: number[], options?: { status?: string }): Promise<DeliveryWithRelations[]> {
|
||
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<DeliveryWithRelations[]> {
|
||
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<DeliveryWithRelations | undefined> {
|
||
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<Delivery> {
|
||
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<InsertDelivery>): Promise<Delivery> {
|
||
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<void> {
|
||
this.deliveries.delete(id);
|
||
}
|
||
|
||
async validateDelivery(id: number, blData?: { blNumber: string; blAmount: number }): Promise<void> {
|
||
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<UserGroup[]> { return []; }
|
||
async assignUserToGroup(): Promise<UserGroup> { return {} as UserGroup; }
|
||
async removeUserFromGroup(): Promise<void> { }
|
||
async removeUserFromAllGroups(userId: string): Promise<void> {
|
||
this.userGroups.delete(userId);
|
||
}
|
||
|
||
async getMonthlyStats(): Promise<any> {
|
||
return {
|
||
ordersCount: 12,
|
||
deliveriesCount: 8,
|
||
pendingOrdersCount: 3,
|
||
averageDeliveryTime: 2.5,
|
||
totalPalettes: 45,
|
||
totalPackages: 156,
|
||
};
|
||
}
|
||
|
||
async getYearlyStats(): Promise<any> {
|
||
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<PublicityWithRelations[]> {
|
||
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<PublicityWithRelations | undefined> { return undefined; }
|
||
async createPublicity(): Promise<Publicity> { return {} as Publicity; }
|
||
async updatePublicity(): Promise<Publicity> { return {} as Publicity; }
|
||
async deletePublicity(): Promise<void> { }
|
||
async getPublicityParticipations(): Promise<PublicityParticipation[]> { return []; }
|
||
async setPublicityParticipations(): Promise<void> { }
|
||
|
||
async getCustomerOrders(groupIds?: number[]): Promise<CustomerOrderWithRelations[]> {
|
||
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<CustomerOrderWithRelations | undefined> {
|
||
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<CustomerOrder> {
|
||
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<InsertCustomerOrder>): Promise<CustomerOrder> {
|
||
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<void> {
|
||
this.customerOrders.delete(id);
|
||
}
|
||
|
||
async getPendingClientCalls(groupIds?: number[]): Promise<CustomerOrderWithRelations[]> {
|
||
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<CustomerOrder> {
|
||
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<DlcProductWithRelations[]> {
|
||
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<DlcProductWithRelations | undefined> {
|
||
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<DlcProductFrontend> {
|
||
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<InsertDlcProduct>): Promise<DlcProductFrontend> {
|
||
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<void> {
|
||
this.dlcProducts.delete(id);
|
||
}
|
||
|
||
async validateDlcProduct(id: number, validatedBy: string): Promise<DlcProductFrontend> {
|
||
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<DlcProductFrontend> {
|
||
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<DlcProductFrontend> {
|
||
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<DlcProductFrontend> {
|
||
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<DlcProductFrontend> {
|
||
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<TaskWithRelations[]> {
|
||
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<TaskWithRelations | undefined> {
|
||
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<Task> {
|
||
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<InsertTask>): Promise<Task> {
|
||
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<void> {
|
||
this.tasks.delete(id);
|
||
}
|
||
|
||
async completeTask(id: number, completedBy: string): Promise<void> {
|
||
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<SavTicketWithRelations[]> {
|
||
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<SavTicketWithRelations | undefined> {
|
||
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<SavTicket> {
|
||
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<InsertSavTicket>): Promise<SavTicket> {
|
||
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<void> {
|
||
// Delete history first
|
||
// this.savTicketHistories.delete(id);
|
||
// Delete ticket
|
||
this.savTickets.delete(id);
|
||
}
|
||
|
||
async getSavTicketHistory(ticketId: number): Promise<SavTicketHistoryWithCreator[]> {
|
||
return [];
|
||
}
|
||
|
||
async addSavTicketHistory(historyData: InsertSavTicketHistory): Promise<any> {
|
||
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<WeatherSettings | undefined> {
|
||
// 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<WeatherSettings> {
|
||
// 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<InsertWeatherSettings>): Promise<WeatherSettings> {
|
||
const existing = await this.getWeatherSettings();
|
||
return {
|
||
...existing!,
|
||
...settings,
|
||
updatedAt: new Date()
|
||
};
|
||
}
|
||
|
||
async getWeatherData(date: string, isCurrentYear: boolean): Promise<WeatherData | undefined> {
|
||
// In development, return some mock data for testing
|
||
return undefined; // Will trigger API calls
|
||
}
|
||
|
||
async getNearestWeatherData(date: string, isCurrentYear: boolean, maxDistanceDays: number): Promise<WeatherData | undefined> {
|
||
return undefined;
|
||
}
|
||
|
||
async createWeatherData(data: InsertWeatherData): Promise<WeatherData> {
|
||
return {
|
||
id: 1,
|
||
...data,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date()
|
||
};
|
||
}
|
||
|
||
async updateWeatherData(id: number, data: Partial<InsertWeatherData>): Promise<WeatherData> {
|
||
const existing = await this.getWeatherData(data.date || "", data.isCurrentYear || true);
|
||
return {
|
||
...existing!,
|
||
...data,
|
||
updatedAt: new Date()
|
||
};
|
||
}
|
||
|
||
async deleteOldWeatherData(daysToKeep: number): Promise<void> {
|
||
// In development, this is a no-op
|
||
console.log(`🧹 DEV: Would delete weather data older than ${daysToKeep} days`);
|
||
}
|
||
|
||
async clearWeatherCache(): Promise<void> {
|
||
// In development, this is a no-op
|
||
console.log('🧹 DEV: Weather cache cleared due to location change');
|
||
}
|
||
|
||
async markDeliveryControlValidated(id: number, userId: string): Promise<void> {
|
||
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<SupplierMailLog> {
|
||
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<SupplierMailLog[]> {
|
||
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<WebhookBapConfig | undefined> {
|
||
// 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<WebhookBapConfig> {
|
||
return {
|
||
id: 1,
|
||
...config,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date()
|
||
};
|
||
}
|
||
|
||
async updateWebhookBapConfig(id: number, config: Partial<InsertWebhookBapConfig>): Promise<WebhookBapConfig> {
|
||
const existing = await this.getWebhookBapConfig();
|
||
return {
|
||
...existing!,
|
||
...config,
|
||
updatedAt: new Date()
|
||
};
|
||
}
|
||
|
||
// Utilities operations
|
||
async getUtilities(): Promise<Utilities | undefined> {
|
||
// MemStorage ne stocke pas les utilities, retourne undefined
|
||
return undefined;
|
||
}
|
||
|
||
async createUtilities(utilitiesData: InsertUtilities): Promise<Utilities> {
|
||
// 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<InsertUtilities>): Promise<Utilities> {
|
||
// 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<Announcement> {
|
||
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<AnnouncementWithRelations[]> {
|
||
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<AnnouncementWithRelations | null | undefined> {
|
||
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<InsertAnnouncement>): Promise<DashboardMessage> {
|
||
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<boolean> {
|
||
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<AvoirWithRelations[]> {
|
||
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<AvoirWithRelations | undefined> {
|
||
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<Avoir> {
|
||
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<InsertAvoir>): Promise<Avoir> {
|
||
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<void> {
|
||
this.avoirs.delete(id);
|
||
}
|
||
|
||
async updateAvoirWebhookStatus(id: number, webhookSent: boolean): Promise<void> {
|
||
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<void> {
|
||
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<ReconciliationCommentWithRelations[]> {
|
||
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<ReconciliationCommentWithRelations | undefined> {
|
||
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<ReconciliationComment> {
|
||
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<InsertReconciliationComment>): Promise<ReconciliationComment> {
|
||
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<void> {
|
||
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<number, { name: string; count: number; amount: number }>();
|
||
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<number, { name: string; orders: number; deliveries: number }>();
|
||
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<Array<{ date: string; orders: number; deliveries: number }>> {
|
||
const dataMap = new Map<string, { orders: number; deliveries: number }>();
|
||
|
||
// 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<Array<{ supplierId: number; supplierName: string; deliveries: number; amount: number }>> {
|
||
const supplierStats = new Map<number, { name: string; deliveries: number; amount: number }>();
|
||
|
||
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<Array<{ groupId: number; storeName: string; orders: number; deliveries: number }>> {
|
||
const storeStats = new Map<number, { name: string; orders: number; deliveries: number }>();
|
||
|
||
// 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)');
|
||
} |