/** * Analytics Service * * Calculates KPIs and analytics data for dashboard */ import { Injectable } from '@nestjs/common'; import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; import { CsvBooking, CsvBookingStatus } from '@domain/entities/csv-booking.entity'; export interface DashboardKPIs { bookingsThisMonth: number; /** Volume LCL du mois, en CBM. */ volumeCBM: number; /** Commission Xpeditis encaissee sur les reservations acceptees, en EUR. */ estimatedRevenue: number; pendingConfirmations: number; bookingsThisMonthChange: number; // % change from last month volumeCBMChange: number; estimatedRevenueChange: number; pendingConfirmationsChange: number; } export interface BookingsChartData { /** Cles de mois ISO `YYYY-MM` ; la mise en forme revient au client. */ labels: string[]; data: number[]; // Booking counts } export interface TopTradeLane { route: string; originPort: string; destinationPort: string; bookingCount: number; totalVolumeCBM: number; avgPrice: number; } export interface DashboardAlert { id: string; type: 'delay' | 'confirmation' | 'document' | 'payment' | 'info'; severity: 'low' | 'medium' | 'high' | 'critical'; /** Cle de traduction, resolue par le client. */ titleKey: string; messageKey: string; messageParams?: Record; bookingId?: string; bookingNumber?: string; createdAt: Date; isRead: boolean; } export interface CsvBookingKPIs { totalAccepted: number; totalRejected: number; totalPending: number; totalWeightAcceptedKG: number; totalVolumeAcceptedCBM: number; acceptanceRate: number; // percentage acceptedThisMonth: number; rejectedThisMonth: number; } export interface TopCarrier { carrierName: string; totalBookings: number; acceptedBookings: number; rejectedBookings: number; acceptanceRate: number; totalWeightKG: number; totalVolumeCBM: number; avgPriceUSD: number; } @Injectable() export class AnalyticsService { constructor(private readonly csvBookingRepository: TypeOrmCsvBookingRepository) {} /** * Calculate dashboard KPIs * * Source : `csv_bookings`. Les quatre methodes de ce bloc lisaient * auparavant `bookingRepository.findByOrganization()`, c'est-a-dire la table * `bookings` — absente du schema (aucune migration ne la cree). Les quatre * endpoints repondaient donc 500 en permanence. Les reservations reelles du * produit sont des reservations LCL portees par `csv_bookings`. */ async calculateKPIs(organizationId: string): Promise { const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId); const now = new Date(); const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); const thisMonth = bookings.filter((b: CsvBooking) => b.requestedAt >= thisMonthStart); const lastMonth = bookings.filter( (b: CsvBooking) => b.requestedAt >= lastMonthStart && b.requestedAt < thisMonthStart ); // LCL : le volume se mesure en CBM. Le TEU, unite du conteneur complet, ne // veut rien dire ici — c'est ce que l'ancienne version calculait. const volumeOf = (list: CsvBooking[]): number => list.reduce((sum, b) => sum + (Number(b.volumeCBM) || 0), 0); // Le chiffre d'affaires est celui d'Xpeditis : la commission percue, pas le // montant du fret qui revient au transporteur. Seules les reservations // acceptees comptent. const revenueOf = (list: CsvBooking[]): number => list .filter(b => b.status === CsvBookingStatus.ACCEPTED) .reduce((sum, b) => sum + (Number(b.commissionAmountEur) || 0), 0); const pendingOf = (list: CsvBooking[]): number => list.filter(b => b.status === CsvBookingStatus.PENDING).length; const volumeThisMonth = volumeOf(thisMonth); const volumeLastMonth = volumeOf(lastMonth); const revenueThisMonth = revenueOf(thisMonth); const revenueLastMonth = revenueOf(lastMonth); const pendingThisMonth = pendingOf(thisMonth); const pendingLastMonth = pendingOf(lastMonth); const change = (current: number, previous: number): number => { if (previous === 0) return current > 0 ? 100 : 0; return ((current - previous) / previous) * 100; }; return { bookingsThisMonth: thisMonth.length, volumeCBM: volumeThisMonth, estimatedRevenue: revenueThisMonth, pendingConfirmations: pendingThisMonth, bookingsThisMonthChange: change(thisMonth.length, lastMonth.length), volumeCBMChange: change(volumeThisMonth, volumeLastMonth), estimatedRevenueChange: change(revenueThisMonth, revenueLastMonth), pendingConfirmationsChange: change(pendingThisMonth, pendingLastMonth), }; } /** * Get bookings chart data for last 6 months * * Les etiquettes sont des cles ISO `YYYY-MM` : le service ignore la langue de * l'utilisateur, la mise en forme du mois revient au client. */ async getBookingsChartData(organizationId: string): Promise { const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId); const now = new Date(); const labels: string[] = []; const data: number[] = []; for (let i = 5; i >= 0; i--) { const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); const nextMonthStart = new Date(now.getFullYear(), now.getMonth() - i + 1, 1); labels.push( `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}` ); data.push( bookings.filter( (b: CsvBooking) => b.requestedAt >= monthStart && b.requestedAt < nextMonthStart ).length ); } return { labels, data }; } /** * Get top 5 trade lanes */ async getTopTradeLanes(organizationId: string): Promise { const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId); const routeMap = new Map< string, { originPort: string; destinationPort: string; bookingCount: number; totalVolumeCBM: number; totalPrice: number; } >(); for (const booking of bookings) { const originPort = booking.origin.getValue(); const destinationPort = booking.destination.getValue(); const routeKey = `${originPort} → ${destinationPort}`; const entry = routeMap.get(routeKey) ?? { originPort, destinationPort, bookingCount: 0, totalVolumeCBM: 0, totalPrice: 0, }; entry.bookingCount++; entry.totalVolumeCBM += Number(booking.volumeCBM) || 0; entry.totalPrice += Number(booking.priceEUR) || 0; routeMap.set(routeKey, entry); } return Array.from(routeMap.entries()) .map(([route, entry]) => ({ route, originPort: entry.originPort, destinationPort: entry.destinationPort, bookingCount: entry.bookingCount, totalVolumeCBM: entry.totalVolumeCBM, avgPrice: entry.bookingCount > 0 ? entry.totalPrice / entry.bookingCount : 0, })) .sort((a, b) => b.bookingCount - a.bookingCount) .slice(0, 5); } /** * Get dashboard alerts * * Uniquement ce sur quoi l'utilisateur peut agir. Les libelles sont des cles * de traduction resolues cote client : le service ne connait pas la langue. */ async getAlerts(organizationId: string): Promise { const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId); const alerts: DashboardAlert[] = []; const now = Date.now(); const oneDay = 24 * 60 * 60 * 1000; for (const booking of bookings) { const waitedMs = now - booking.requestedAt.getTime(); const waitedDays = Math.floor(waitedMs / oneDay); // Sans reponse du transporteur au-dela de 48 h, la demande decroche. if (booking.status === CsvBookingStatus.PENDING && waitedMs > 2 * oneDay) { alerts.push({ id: `pending-${booking.id}`, type: 'confirmation', severity: waitedDays >= 5 ? 'high' : 'medium', titleKey: 'awaitingCarrier', messageKey: 'awaitingCarrierSince', messageParams: { days: waitedDays, carrier: booking.carrierName }, bookingId: booking.id, bookingNumber: booking.bookingNumber, createdAt: booking.requestedAt, isRead: false, }); continue; } // Reservation bloquee avant paiement : rien ne part tant qu'elle y reste. if ( (booking.status === CsvBookingStatus.PENDING_PAYMENT || booking.status === CsvBookingStatus.PENDING_BANK_TRANSFER) && waitedMs > oneDay ) { alerts.push({ id: `payment-${booking.id}`, type: 'payment', severity: 'critical', titleKey: 'awaitingPayment', messageKey: 'awaitingPaymentSince', messageParams: { days: waitedDays }, bookingId: booking.id, bookingNumber: booking.bookingNumber, createdAt: booking.requestedAt, isRead: false, }); continue; } // Refus recent : il faut replacer la marchandise. if ( booking.status === CsvBookingStatus.REJECTED && booking.respondedAt && now - booking.respondedAt.getTime() < 7 * oneDay ) { alerts.push({ id: `rejected-${booking.id}`, type: 'info', severity: 'medium', titleKey: 'rejected', messageKey: 'rejectedBy', messageParams: { carrier: booking.carrierName }, bookingId: booking.id, bookingNumber: booking.bookingNumber, createdAt: booking.respondedAt, isRead: false, }); } } const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; return alerts.sort( (a, b) => severityOrder[a.severity] - severityOrder[b.severity] || b.createdAt.getTime() - a.createdAt.getTime() ); } /** * Get CSV Booking KPIs */ async getCsvBookingKPIs(organizationId: string): Promise { const allCsvBookings = await this.csvBookingRepository.findByOrganizationId(organizationId); const now = new Date(); const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); // Filter by status const acceptedBookings = allCsvBookings.filter( (b: CsvBooking) => b.status === CsvBookingStatus.ACCEPTED ); const rejectedBookings = allCsvBookings.filter( (b: CsvBooking) => b.status === CsvBookingStatus.REJECTED ); const pendingBookings = allCsvBookings.filter( (b: CsvBooking) => b.status === CsvBookingStatus.PENDING ); // This month stats const acceptedThisMonth = acceptedBookings.filter( (b: CsvBooking) => b.requestedAt >= thisMonthStart ).length; const rejectedThisMonth = rejectedBookings.filter( (b: CsvBooking) => b.requestedAt >= thisMonthStart ).length; // Calculate total weight and volume for accepted bookings const totalWeightAcceptedKG = acceptedBookings.reduce( (sum: number, b: CsvBooking) => sum + b.weightKG, 0 ); const totalVolumeAcceptedCBM = acceptedBookings.reduce( (sum: number, b: CsvBooking) => sum + b.volumeCBM, 0 ); // Calculate acceptance rate const totalProcessed = acceptedBookings.length + rejectedBookings.length; const acceptanceRate = totalProcessed > 0 ? (acceptedBookings.length / totalProcessed) * 100 : 0; return { totalAccepted: acceptedBookings.length, totalRejected: rejectedBookings.length, totalPending: pendingBookings.length, totalWeightAcceptedKG, totalVolumeAcceptedCBM, acceptanceRate, acceptedThisMonth, rejectedThisMonth, }; } /** * Get Top Carriers by booking count */ async getTopCarriers(organizationId: string, limit: number = 5): Promise { const allCsvBookings = await this.csvBookingRepository.findByOrganizationId(organizationId); // Group by carrier const carrierMap = new Map< string, { totalBookings: number; acceptedBookings: number; rejectedBookings: number; totalWeightKG: number; totalVolumeCBM: number; totalPriceUSD: number; } >(); for (const booking of allCsvBookings) { const carrierName = booking.carrierName; if (!carrierMap.has(carrierName)) { carrierMap.set(carrierName, { totalBookings: 0, acceptedBookings: 0, rejectedBookings: 0, totalWeightKG: 0, totalVolumeCBM: 0, totalPriceUSD: 0, }); } const carrier = carrierMap.get(carrierName)!; carrier.totalBookings++; if (booking.status === CsvBookingStatus.ACCEPTED) { carrier.acceptedBookings++; carrier.totalWeightKG += booking.weightKG; carrier.totalVolumeCBM += booking.volumeCBM; } if (booking.status === CsvBookingStatus.REJECTED) { carrier.rejectedBookings++; } // Add price (prefer USD, fallback to EUR converted) if (booking.priceUSD) { carrier.totalPriceUSD += booking.priceUSD; } else if (booking.priceEUR) { // Simple EUR to USD conversion (1.1 rate) - in production, use real exchange rate carrier.totalPriceUSD += booking.priceEUR * 1.1; } } // Convert to array const topCarriers: TopCarrier[] = Array.from(carrierMap.entries()).map( ([carrierName, data]) => ({ carrierName, totalBookings: data.totalBookings, acceptedBookings: data.acceptedBookings, rejectedBookings: data.rejectedBookings, acceptanceRate: data.totalBookings > 0 ? (data.acceptedBookings / data.totalBookings) * 100 : 0, totalWeightKG: data.totalWeightKG, totalVolumeCBM: data.totalVolumeCBM, avgPriceUSD: data.totalBookings > 0 ? data.totalPriceUSD / data.totalBookings : 0, }) ); // Sort by total bookings (most bookings first) return topCarriers.sort((a, b) => b.totalBookings - a.totalBookings).slice(0, limit); } }