432 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|