xpeditis2.0/apps/backend/src/application/services/analytics.service.ts
2026-08-13 17:43:11 +02:00

432 lines
14 KiB
TypeScript

/**
* Analytics Service
*
* Calculates KPIs and analytics data for dashboard
*/
import { Injectable } from '@nestjs/common';
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import { CsvBooking, CsvBookingStatus } from '@domain/entities/csv-booking.entity';
export interface DashboardKPIs {
bookingsThisMonth: number;
/** Volume LCL du mois, en CBM. */
volumeCBM: number;
/** Commission Xpeditis encaissee sur les reservations acceptees, en EUR. */
estimatedRevenue: number;
pendingConfirmations: number;
bookingsThisMonthChange: number; // % change from last month
volumeCBMChange: number;
estimatedRevenueChange: number;
pendingConfirmationsChange: number;
}
export interface BookingsChartData {
/** Cles de mois ISO `YYYY-MM` ; la mise en forme revient au client. */
labels: string[];
data: number[]; // Booking counts
}
export interface TopTradeLane {
route: string;
originPort: string;
destinationPort: string;
bookingCount: number;
totalVolumeCBM: number;
avgPrice: number;
}
export interface DashboardAlert {
id: string;
type: 'delay' | 'confirmation' | 'document' | 'payment' | 'info';
severity: 'low' | 'medium' | 'high' | 'critical';
/** Cle de traduction, resolue par le client. */
titleKey: string;
messageKey: string;
messageParams?: Record<string, string | number>;
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(private readonly csvBookingRepository: TypeOrmCsvBookingRepository) {}
/**
* Calculate dashboard KPIs
*
* Source : `csv_bookings`. Les quatre methodes de ce bloc lisaient
* auparavant `bookingRepository.findByOrganization()`, c'est-a-dire la table
* `bookings` — absente du schema (aucune migration ne la cree). Les quatre
* endpoints repondaient donc 500 en permanence. Les reservations reelles du
* produit sont des reservations LCL portees par `csv_bookings`.
*/
async calculateKPIs(organizationId: string): Promise<DashboardKPIs> {
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
const now = new Date();
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const thisMonth = bookings.filter((b: CsvBooking) => b.requestedAt >= thisMonthStart);
const lastMonth = bookings.filter(
(b: CsvBooking) => b.requestedAt >= lastMonthStart && b.requestedAt < thisMonthStart
);
// LCL : le volume se mesure en CBM. Le TEU, unite du conteneur complet, ne
// veut rien dire ici — c'est ce que l'ancienne version calculait.
const volumeOf = (list: CsvBooking[]): number =>
list.reduce((sum, b) => sum + (Number(b.volumeCBM) || 0), 0);
// Le chiffre d'affaires est celui d'Xpeditis : la commission percue, pas le
// montant du fret qui revient au transporteur. Seules les reservations
// acceptees comptent.
const revenueOf = (list: CsvBooking[]): number =>
list
.filter(b => b.status === CsvBookingStatus.ACCEPTED)
.reduce((sum, b) => sum + (Number(b.commissionAmountEur) || 0), 0);
const pendingOf = (list: CsvBooking[]): number =>
list.filter(b => b.status === CsvBookingStatus.PENDING).length;
const volumeThisMonth = volumeOf(thisMonth);
const volumeLastMonth = volumeOf(lastMonth);
const revenueThisMonth = revenueOf(thisMonth);
const revenueLastMonth = revenueOf(lastMonth);
const pendingThisMonth = pendingOf(thisMonth);
const pendingLastMonth = pendingOf(lastMonth);
const change = (current: number, previous: number): number => {
if (previous === 0) return current > 0 ? 100 : 0;
return ((current - previous) / previous) * 100;
};
return {
bookingsThisMonth: thisMonth.length,
volumeCBM: volumeThisMonth,
estimatedRevenue: revenueThisMonth,
pendingConfirmations: pendingThisMonth,
bookingsThisMonthChange: change(thisMonth.length, lastMonth.length),
volumeCBMChange: change(volumeThisMonth, volumeLastMonth),
estimatedRevenueChange: change(revenueThisMonth, revenueLastMonth),
pendingConfirmationsChange: change(pendingThisMonth, pendingLastMonth),
};
}
/**
* Get bookings chart data for last 6 months
*
* Les etiquettes sont des cles ISO `YYYY-MM` : le service ignore la langue de
* l'utilisateur, la mise en forme du mois revient au client.
*/
async getBookingsChartData(organizationId: string): Promise<BookingsChartData> {
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
const now = new Date();
const labels: string[] = [];
const data: number[] = [];
for (let i = 5; i >= 0; i--) {
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
const nextMonthStart = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
labels.push(
`${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}`
);
data.push(
bookings.filter(
(b: CsvBooking) => b.requestedAt >= monthStart && b.requestedAt < nextMonthStart
).length
);
}
return { labels, data };
}
/**
* Get top 5 trade lanes
*/
async getTopTradeLanes(organizationId: string): Promise<TopTradeLane[]> {
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
const routeMap = new Map<
string,
{
originPort: string;
destinationPort: string;
bookingCount: number;
totalVolumeCBM: number;
totalPrice: number;
}
>();
for (const booking of bookings) {
const originPort = booking.origin.getValue();
const destinationPort = booking.destination.getValue();
const routeKey = `${originPort} → ${destinationPort}`;
const entry = routeMap.get(routeKey) ?? {
originPort,
destinationPort,
bookingCount: 0,
totalVolumeCBM: 0,
totalPrice: 0,
};
entry.bookingCount++;
entry.totalVolumeCBM += Number(booking.volumeCBM) || 0;
entry.totalPrice += Number(booking.priceEUR) || 0;
routeMap.set(routeKey, entry);
}
return Array.from(routeMap.entries())
.map(([route, entry]) => ({
route,
originPort: entry.originPort,
destinationPort: entry.destinationPort,
bookingCount: entry.bookingCount,
totalVolumeCBM: entry.totalVolumeCBM,
avgPrice: entry.bookingCount > 0 ? entry.totalPrice / entry.bookingCount : 0,
}))
.sort((a, b) => b.bookingCount - a.bookingCount)
.slice(0, 5);
}
/**
* Get dashboard alerts
*
* Uniquement ce sur quoi l'utilisateur peut agir. Les libelles sont des cles
* de traduction resolues cote client : le service ne connait pas la langue.
*/
async getAlerts(organizationId: string): Promise<DashboardAlert[]> {
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
const alerts: DashboardAlert[] = [];
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
for (const booking of bookings) {
const waitedMs = now - booking.requestedAt.getTime();
const waitedDays = Math.floor(waitedMs / oneDay);
// Sans reponse du transporteur au-dela de 48 h, la demande decroche.
if (booking.status === CsvBookingStatus.PENDING && waitedMs > 2 * oneDay) {
alerts.push({
id: `pending-${booking.id}`,
type: 'confirmation',
severity: waitedDays >= 5 ? 'high' : 'medium',
titleKey: 'awaitingCarrier',
messageKey: 'awaitingCarrierSince',
messageParams: { days: waitedDays, carrier: booking.carrierName },
bookingId: booking.id,
bookingNumber: booking.bookingNumber,
createdAt: booking.requestedAt,
isRead: false,
});
continue;
}
// Reservation bloquee avant paiement : rien ne part tant qu'elle y reste.
if (
(booking.status === CsvBookingStatus.PENDING_PAYMENT ||
booking.status === CsvBookingStatus.PENDING_BANK_TRANSFER) &&
waitedMs > oneDay
) {
alerts.push({
id: `payment-${booking.id}`,
type: 'payment',
severity: 'critical',
titleKey: 'awaitingPayment',
messageKey: 'awaitingPaymentSince',
messageParams: { days: waitedDays },
bookingId: booking.id,
bookingNumber: booking.bookingNumber,
createdAt: booking.requestedAt,
isRead: false,
});
continue;
}
// Refus recent : il faut replacer la marchandise.
if (
booking.status === CsvBookingStatus.REJECTED &&
booking.respondedAt &&
now - booking.respondedAt.getTime() < 7 * oneDay
) {
alerts.push({
id: `rejected-${booking.id}`,
type: 'info',
severity: 'medium',
titleKey: 'rejected',
messageKey: 'rejectedBy',
messageParams: { carrier: booking.carrierName },
bookingId: booking.id,
bookingNumber: booking.bookingNumber,
createdAt: booking.respondedAt,
isRead: false,
});
}
}
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
return alerts.sort(
(a, b) =>
severityOrder[a.severity] - severityOrder[b.severity] ||
b.createdAt.getTime() - a.createdAt.getTime()
);
}
/**
* 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);
}
}