xpeditis2.0/apps/backend/src/application/services/analytics.service.ts
2025-12-16 00:26:03 +01:00

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);
}
}