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