/** * Analytics Service * * Calculates KPIs and analytics data for dashboard */ import { Injectable, Inject } from '@nestjs/common'; import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository'; import { BookingRepository } from '@domain/ports/out/booking.repository'; import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository'; import { RateQuoteRepository } from '@domain/ports/out/rate-quote.repository'; import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; import { CsvBooking, CsvBookingStatus } from '@domain/entities/csv-booking.entity'; export interface DashboardKPIs { bookingsThisMonth: number; totalTEUs: number; estimatedRevenue: number; pendingConfirmations: number; bookingsThisMonthChange: number; // % change from last month totalTEUsChange: number; estimatedRevenueChange: number; pendingConfirmationsChange: number; } export interface BookingsChartData { labels: string[]; // Month names data: number[]; // Booking counts } export interface TopTradeLane { route: string; originPort: string; destinationPort: string; bookingCount: number; totalTEUs: number; avgPrice: number; } export interface DashboardAlert { id: string; type: 'delay' | 'confirmation' | 'document' | 'payment' | 'info'; severity: 'low' | 'medium' | 'high' | 'critical'; title: string; message: string; 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( @Inject(BOOKING_REPOSITORY) private readonly bookingRepository: BookingRepository, @Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository, private readonly csvBookingRepository: TypeOrmCsvBookingRepository ) {} /** * Calculate dashboard KPIs * Cached for 1 hour */ async calculateKPIs(organizationId: string): Promise { const now = new Date(); const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59); // Get all bookings for organization const allBookings = await this.bookingRepository.findByOrganization(organizationId); // This month bookings const thisMonthBookings = allBookings.filter(b => b.createdAt >= thisMonthStart); // Last month bookings const lastMonthBookings = allBookings.filter( b => b.createdAt >= lastMonthStart && b.createdAt <= lastMonthEnd ); // Calculate total TEUs (20' = 1 TEU, 40' = 2 TEU) // Each container is an individual entity, so we count them const calculateTEUs = (bookings: typeof allBookings): number => { return bookings.reduce((total, booking) => { return ( total + booking.containers.reduce((containerTotal, container) => { const teu = container.type.startsWith('20') ? 1 : 2; return containerTotal + teu; // Each container counts as 1 or 2 TEU }, 0) ); }, 0); }; const totalTEUsThisMonth = calculateTEUs(thisMonthBookings); const totalTEUsLastMonth = calculateTEUs(lastMonthBookings); // Calculate estimated revenue (from rate quotes) const calculateRevenue = async (bookings: typeof allBookings): Promise => { let total = 0; for (const booking of bookings) { try { const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); if (rateQuote) { total += rateQuote.pricing.totalAmount; } } catch (error) { // Skip if rate quote not found continue; } } return total; }; const estimatedRevenueThisMonth = await calculateRevenue(thisMonthBookings); const estimatedRevenueLastMonth = await calculateRevenue(lastMonthBookings); // Pending confirmations (status = pending_confirmation) const pendingThisMonth = thisMonthBookings.filter( b => b.status.value === 'pending_confirmation' ).length; const pendingLastMonth = lastMonthBookings.filter( b => b.status.value === 'pending_confirmation' ).length; // Calculate percentage changes const calculateChange = (current: number, previous: number): number => { if (previous === 0) return current > 0 ? 100 : 0; return ((current - previous) / previous) * 100; }; return { bookingsThisMonth: thisMonthBookings.length, totalTEUs: totalTEUsThisMonth, estimatedRevenue: estimatedRevenueThisMonth, pendingConfirmations: pendingThisMonth, bookingsThisMonthChange: calculateChange(thisMonthBookings.length, lastMonthBookings.length), totalTEUsChange: calculateChange(totalTEUsThisMonth, totalTEUsLastMonth), estimatedRevenueChange: calculateChange(estimatedRevenueThisMonth, estimatedRevenueLastMonth), pendingConfirmationsChange: calculateChange(pendingThisMonth, pendingLastMonth), }; } /** * Get bookings chart data for last 6 months */ async getBookingsChartData(organizationId: string): Promise { const now = new Date(); const labels: string[] = []; const data: number[] = []; // Get bookings for last 6 months const allBookings = await this.bookingRepository.findByOrganization(organizationId); for (let i = 5; i >= 0; i--) { const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1); const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0, 23, 59, 59); // Month label (e.g., "Jan 2025") const monthLabel = monthDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric', }); labels.push(monthLabel); // Count bookings in this month const count = allBookings.filter( b => b.createdAt >= monthDate && b.createdAt <= monthEnd ).length; data.push(count); } return { labels, data }; } /** * Get top 5 trade lanes */ async getTopTradeLanes(organizationId: string): Promise { const allBookings = await this.bookingRepository.findByOrganization(organizationId); // Group by route (origin-destination) const routeMap = new Map< string, { originPort: string; destinationPort: string; bookingCount: number; totalTEUs: number; totalPrice: number; } >(); for (const booking of allBookings) { try { const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); if (!rateQuote) continue; // Get first and last ports from route const originPort = rateQuote.route[0]?.portCode || 'UNKNOWN'; const destinationPort = rateQuote.route[rateQuote.route.length - 1]?.portCode || 'UNKNOWN'; const routeKey = `${originPort}-${destinationPort}`; if (!routeMap.has(routeKey)) { routeMap.set(routeKey, { originPort, destinationPort, bookingCount: 0, totalTEUs: 0, totalPrice: 0, }); } const route = routeMap.get(routeKey)!; route.bookingCount++; route.totalPrice += rateQuote.pricing.totalAmount; // Calculate TEUs const teus = booking.containers.reduce((total, container) => { const teu = container.type.startsWith('20') ? 1 : 2; return total + teu; }, 0); route.totalTEUs += teus; } catch (error) { continue; } } // Convert to array and sort by booking count const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map(([route, data]) => ({ route, originPort: data.originPort, destinationPort: data.destinationPort, bookingCount: data.bookingCount, totalTEUs: data.totalTEUs, avgPrice: data.totalPrice / data.bookingCount, })); // Sort by booking count and return top 5 return tradeLanes.sort((a, b) => b.bookingCount - a.bookingCount).slice(0, 5); } /** * Get dashboard alerts */ async getAlerts(organizationId: string): Promise { const alerts: DashboardAlert[] = []; const allBookings = await this.bookingRepository.findByOrganization(organizationId); // Check for pending confirmations (older than 24h) const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const oldPendingBookings = allBookings.filter( b => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo ); for (const booking of oldPendingBookings) { alerts.push({ id: `pending-${booking.id}`, type: 'confirmation', severity: 'medium', title: 'Pending Confirmation', message: `Booking ${booking.bookingNumber.value} is awaiting carrier confirmation for over 24 hours`, bookingId: booking.id, bookingNumber: booking.bookingNumber.value, createdAt: booking.createdAt, isRead: false, }); } // Check for bookings departing soon (within 7 days) with pending status const sevenDaysFromNow = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); for (const booking of allBookings) { try { const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); if (rateQuote && rateQuote.route.length > 0) { const etd = rateQuote.route[0].departure; if (etd) { const etdDate = new Date(etd); if ( etdDate <= sevenDaysFromNow && etdDate >= new Date() && booking.status.value === 'pending_confirmation' ) { alerts.push({ id: `departure-${booking.id}`, type: 'delay', severity: 'high', title: 'Departure Soon - Not Confirmed', message: `Booking ${booking.bookingNumber.value} departs in ${Math.ceil( (etdDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000) )} days but is not confirmed yet`, bookingId: booking.id, bookingNumber: booking.bookingNumber.value, createdAt: booking.createdAt, isRead: false, }); } } } } catch (error) { continue; } } // Sort by severity (critical > high > medium > low) const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; alerts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); return alerts; } /** * 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); } }