/** * 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'; 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; } @Injectable() export class AnalyticsService { constructor( @Inject(BOOKING_REPOSITORY) private readonly bookingRepository: BookingRepository, @Inject(RATE_QUOTE_REPOSITORY) private readonly rateQuoteRepository: RateQuoteRepository ) {} /** * 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; } }