xpeditis2.0/apps/backend/src/application/services/analytics.service.ts
David 4b00ee2601
Some checks failed
CI/CD Pipeline - Xpeditis PreProd / Frontend - Docker Build & Push (push) Blocked by required conditions
CI/CD Pipeline - Xpeditis PreProd / Deploy to PreProd Server (push) Blocked by required conditions
CI/CD Pipeline - Xpeditis PreProd / Run Smoke Tests (push) Blocked by required conditions
CI/CD Pipeline - Xpeditis PreProd / Backend - Build & Test (push) Failing after 5m53s
CI/CD Pipeline - Xpeditis PreProd / Backend - Docker Build & Push (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Frontend - Build & Test (push) Has been cancelled
fix: replace relative domain imports with TypeScript path aliases
- Replace all ../../domain/ imports with @domain/ across 67 files
- Configure NestJS to use tsconfig.build.json with rootDir
- Add tsc-alias to resolve path aliases after build
- This fixes 'Cannot find module' TypeScript compilation errors

Fixed files:
- 30 files in application layer
- 37 files in infrastructure layer
2025-11-16 19:20:58 +01:00

311 lines
10 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';
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<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;
}
}