465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
/**
|
|
* 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<DashboardKPIs> {
|
|
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<number> => {
|
|
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<BookingsChartData> {
|
|
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<TopTradeLane[]> {
|
|
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<DashboardAlert[]> {
|
|
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<CsvBookingKPIs> {
|
|
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<TopCarrier[]> {
|
|
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);
|
|
}
|
|
}
|