feature dashboard

This commit is contained in:
David 2025-12-16 00:26:03 +01:00
parent 71541c79e7
commit eab3d6f612
29 changed files with 1628 additions and 1365 deletions

View File

@ -11,7 +11,10 @@ import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { User, UserRole } from '@domain/entities/user.entity';
import { OrganizationRepository, ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
import { v4 as uuidv4 } from 'uuid';
import { DEFAULT_ORG_ID } from '@infrastructure/persistence/typeorm/seeds/test-organizations.seed';
@ -301,7 +304,9 @@ export class AuthService {
const savedOrganization = await this.organizationRepository.save(newOrganization);
this.logger.log(`New organization created: ${savedOrganization.id} - ${savedOrganization.name}`);
this.logger.log(
`New organization created: ${savedOrganization.id} - ${savedOrganization.name}`
);
return savedOrganization.id;
}

View File

@ -181,7 +181,7 @@ export class CsvRatesAdminController {
// Auto-convert CSV if needed (FOB FRET → Standard format)
const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName);
let filePathToValidate = conversionResult.convertedPath;
const filePathToValidate = conversionResult.convertedPath;
if (conversionResult.wasConverted) {
this.logger.log(
@ -204,7 +204,11 @@ export class CsvRatesAdminController {
// Load rates to verify parsing using the converted path
// Pass company name from form to override CSV column value
const rates = await this.csvLoader.loadRatesFromCsv(filePathToValidate, dto.companyEmail, dto.companyName);
const rates = await this.csvLoader.loadRatesFromCsv(
filePathToValidate,
dto.companyEmail,
dto.companyName
);
const ratesCount = rates.length;
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);
@ -245,7 +249,9 @@ export class CsvRatesAdminController {
minioObjectKey = objectKey;
this.logger.log(`✅ CSV file uploaded to MinIO: ${bucket}/${objectKey}`);
} catch (error: any) {
this.logger.error(`⚠️ Failed to upload CSV to MinIO (will continue with local storage): ${error.message}`);
this.logger.error(
`⚠️ Failed to upload CSV to MinIO (will continue with local storage): ${error.message}`
);
// Don't fail the entire operation if MinIO upload fails
// The file is still available locally
}
@ -433,7 +439,8 @@ export class CsvRatesAdminController {
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'List all CSV files (ADMIN only)',
description: 'Returns list of all uploaded CSV files with metadata. Alias for /config endpoint.',
description:
'Returns list of all uploaded CSV files with metadata. Alias for /config endpoint.',
})
@ApiResponse({
status: HttpStatus.OK,
@ -462,7 +469,7 @@ export class CsvRatesAdminController {
const configs = await this.csvConfigRepository.findAll();
// Map configs to file info format expected by frontend
const files = configs.map((config) => {
const files = configs.map(config => {
const filePath = path.join(
process.cwd(),
'apps/backend/src/infrastructure/storage/csv-storage/rates',
@ -521,7 +528,7 @@ export class CsvRatesAdminController {
// Find config by file path
const configs = await this.csvConfigRepository.findAll();
const config = configs.find((c) => c.csvFilePath === filename);
const config = configs.find(c => c.csvFilePath === filename);
if (!config) {
throw new BadRequestException(`No configuration found for file: ${filename}`);

View File

@ -1,93 +1,91 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { Public } from '../decorators/public.decorator';
import { CsvBookingService } from '../services/csv-booking.service';
/**
* CSV Booking Actions Controller (Public Routes)
*
* Handles public accept/reject actions from carrier emails
* Separated from main controller to avoid routing conflicts
*/
@ApiTags('CSV Booking Actions')
@Controller('csv-booking-actions')
export class CsvBookingActionsController {
constructor(
private readonly csvBookingService: CsvBookingService
) {}
/**
* Accept a booking request (PUBLIC - token-based)
*
* GET /api/v1/csv-booking-actions/accept/:token
*/
@Public()
@Get('accept/:token')
@ApiOperation({
summary: 'Accept booking request (public)',
description:
'Public endpoint for carriers to accept a booking via email link. Updates booking status and notifies the user.',
})
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiResponse({
status: 200,
description: 'Booking accepted successfully.',
})
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
@ApiResponse({
status: 400,
description: 'Booking cannot be accepted (invalid status or expired)',
})
async acceptBooking(@Param('token') token: string) {
// Accept the booking
const booking = await this.csvBookingService.acceptBooking(token);
// Return simple success response
return {
success: true,
bookingId: booking.id,
action: 'accepted',
};
}
/**
* Reject a booking request (PUBLIC - token-based)
*
* GET /api/v1/csv-booking-actions/reject/:token
*/
@Public()
@Get('reject/:token')
@ApiOperation({
summary: 'Reject booking request (public)',
description:
'Public endpoint for carriers to reject a booking via email link. Updates booking status and notifies the user.',
})
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiQuery({
name: 'reason',
required: false,
description: 'Rejection reason',
example: 'No capacity available',
})
@ApiResponse({
status: 200,
description: 'Booking rejected successfully.',
})
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
@ApiResponse({
status: 400,
description: 'Booking cannot be rejected (invalid status or expired)',
})
async rejectBooking(@Param('token') token: string, @Query('reason') reason: string) {
// Reject the booking
const booking = await this.csvBookingService.rejectBooking(token, reason);
// Return simple success response
return {
success: true,
bookingId: booking.id,
action: 'rejected',
reason: reason || null,
};
}
}
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import { Public } from '../decorators/public.decorator';
import { CsvBookingService } from '../services/csv-booking.service';
/**
* CSV Booking Actions Controller (Public Routes)
*
* Handles public accept/reject actions from carrier emails
* Separated from main controller to avoid routing conflicts
*/
@ApiTags('CSV Booking Actions')
@Controller('csv-booking-actions')
export class CsvBookingActionsController {
constructor(private readonly csvBookingService: CsvBookingService) {}
/**
* Accept a booking request (PUBLIC - token-based)
*
* GET /api/v1/csv-booking-actions/accept/:token
*/
@Public()
@Get('accept/:token')
@ApiOperation({
summary: 'Accept booking request (public)',
description:
'Public endpoint for carriers to accept a booking via email link. Updates booking status and notifies the user.',
})
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiResponse({
status: 200,
description: 'Booking accepted successfully.',
})
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
@ApiResponse({
status: 400,
description: 'Booking cannot be accepted (invalid status or expired)',
})
async acceptBooking(@Param('token') token: string) {
// Accept the booking
const booking = await this.csvBookingService.acceptBooking(token);
// Return simple success response
return {
success: true,
bookingId: booking.id,
action: 'accepted',
};
}
/**
* Reject a booking request (PUBLIC - token-based)
*
* GET /api/v1/csv-booking-actions/reject/:token
*/
@Public()
@Get('reject/:token')
@ApiOperation({
summary: 'Reject booking request (public)',
description:
'Public endpoint for carriers to reject a booking via email link. Updates booking status and notifies the user.',
})
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
@ApiQuery({
name: 'reason',
required: false,
description: 'Rejection reason',
example: 'No capacity available',
})
@ApiResponse({
status: 200,
description: 'Booking rejected successfully.',
})
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
@ApiResponse({
status: 400,
description: 'Booking cannot be rejected (invalid status or expired)',
})
async rejectBooking(@Param('token') token: string, @Query('reason') reason: string) {
// Reject the booking
const booking = await this.csvBookingService.rejectBooking(token, reason);
// Return simple success response
return {
success: true,
bookingId: booking.id,
action: 'rejected',
reason: reason || null,
};
}
}

View File

@ -47,9 +47,7 @@ import {
@ApiTags('CSV Bookings')
@Controller('csv-bookings')
export class CsvBookingsController {
constructor(
private readonly csvBookingService: CsvBookingService
) {}
constructor(private readonly csvBookingService: CsvBookingService) {}
/**
* Create a new CSV booking request
@ -219,10 +217,7 @@ export class CsvBookingsController {
status: 400,
description: 'Booking cannot be rejected (invalid status or expired)',
})
async rejectBooking(
@Param('token') token: string,
@Query('reason') reason: string
) {
async rejectBooking(@Param('token') token: string, @Query('reason') reason: string) {
// Reject the booking
const booking = await this.csvBookingService.rejectBooking(token, reason);

View File

@ -10,15 +10,13 @@ import {
Param,
ParseUUIDPipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
import { InvitationService } from '../services/invitation.service';
import { CreateInvitationDto, InvitationResponseDto, VerifyInvitationDto } from '../dto/invitation.dto';
import {
CreateInvitationDto,
InvitationResponseDto,
VerifyInvitationDto,
} from '../dto/invitation.dto';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { Roles } from '../decorators/roles.decorator';

View File

@ -16,18 +16,13 @@ import { StorageModule } from '../infrastructure/storage/storage.module';
*/
@Module({
imports: [
TypeOrmModule.forFeature([
CsvBookingOrmEntity,
]),
TypeOrmModule.forFeature([CsvBookingOrmEntity]),
NotificationsModule,
EmailModule,
StorageModule,
],
controllers: [CsvBookingsController, CsvBookingActionsController],
providers: [
CsvBookingService,
TypeOrmCsvBookingRepository,
],
exports: [CsvBookingService],
providers: [CsvBookingService, TypeOrmCsvBookingRepository],
exports: [CsvBookingService, TypeOrmCsvBookingRepository],
})
export class CsvBookingsModule {}

View File

@ -52,4 +52,24 @@ export class DashboardController {
const organizationId = req.user.organizationId;
return this.analyticsService.getAlerts(organizationId);
}
/**
* Get CSV Booking KPIs
* GET /api/v1/dashboard/csv-booking-kpis
*/
@Get('csv-booking-kpis')
async getCsvBookingKPIs(@Request() req: any) {
const organizationId = req.user.organizationId;
return this.analyticsService.getCsvBookingKPIs(organizationId);
}
/**
* Get Top Carriers
* GET /api/v1/dashboard/top-carriers
*/
@Get('top-carriers')
async getTopCarriers(@Request() req: any) {
const organizationId = req.user.organizationId;
return this.analyticsService.getTopCarriers(organizationId, 5);
}
}

View File

@ -7,9 +7,10 @@ import { DashboardController } from './dashboard.controller';
import { AnalyticsService } from '../services/analytics.service';
import { BookingsModule } from '../bookings/bookings.module';
import { RatesModule } from '../rates/rates.module';
import { CsvBookingsModule } from '../csv-bookings.module';
@Module({
imports: [BookingsModule, RatesModule],
imports: [BookingsModule, RatesModule, CsvBookingsModule],
controllers: [DashboardController],
providers: [AnalyticsService],
exports: [AnalyticsService],

View File

@ -1,4 +1,13 @@
import { IsEmail, IsString, MinLength, IsOptional, ValidateNested, IsEnum, MaxLength, Matches } from 'class-validator';
import {
IsEmail,
IsString,
MinLength,
IsOptional,
ValidateNested,
IsEnum,
MaxLength,
Matches,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { OrganizationType } from '@domain/entities/organization.entity';
@ -145,7 +154,8 @@ export class RegisterDto {
@ApiPropertyOptional({
example: '550e8400-e29b-41d4-a716-446655440000',
description: 'Organization ID (optional - for invited users). If not provided, organization data must be provided.',
description:
'Organization ID (optional - for invited users). If not provided, organization data must be provided.',
required: false,
})
@IsString()
@ -153,7 +163,8 @@ export class RegisterDto {
organizationId?: string;
@ApiPropertyOptional({
description: 'Organization data (required if organizationId and invitationToken are not provided)',
description:
'Organization data (required if organizationId and invitationToken are not provided)',
type: RegisterOrganizationDto,
required: false,
})

View File

@ -9,6 +9,8 @@ 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;
@ -47,13 +49,36 @@ export interface DashboardAlert {
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 rateQuoteRepository: RateQuoteRepository,
private readonly csvBookingRepository: TypeOrmCsvBookingRepository
) {}
/**
@ -307,4 +332,133 @@ export class AnalyticsService {
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);
}
}

View File

@ -4,7 +4,13 @@
* Handles carrier authentication and automatic account creation
*/
import { Injectable, Logger, UnauthorizedException, ConflictException, Inject } from '@nestjs/common';
import {
Injectable,
Logger,
UnauthorizedException,
ConflictException,
Inject,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@ -111,7 +117,11 @@ export class CarrierAuthService {
// Send welcome email with credentials and WAIT for confirmation
try {
await this.emailAdapter.sendCarrierAccountCreated(carrierEmail, carrierName, temporaryPassword);
await this.emailAdapter.sendCarrierAccountCreated(
carrierEmail,
carrierName,
temporaryPassword
);
this.logger.log(`Account creation email sent to ${carrierEmail}`);
} catch (error: any) {
this.logger.error(`Failed to send account creation email: ${error?.message}`, error?.stack);
@ -148,7 +158,10 @@ export class CarrierAuthService {
/**
* Standard login for carriers
*/
async login(email: string, password: string): Promise<{
async login(
email: string,
password: string
): Promise<{
accessToken: string;
refreshToken: string;
carrier: {
@ -288,7 +301,11 @@ export class CarrierAuthService {
// Send password reset email and WAIT for confirmation
try {
await this.emailAdapter.sendCarrierPasswordReset(email, carrier.companyName, temporaryPassword);
await this.emailAdapter.sendCarrierPasswordReset(
email,
carrier.companyName,
temporaryPassword
);
this.logger.log(`Password reset email sent to ${email}`);
} catch (error: any) {
this.logger.error(`Failed to send password reset email: ${error?.message}`, error?.stack);

View File

@ -161,7 +161,11 @@ export class CsvBookingService {
* Get booking by ID
* Accessible by: booking owner OR assigned carrier
*/
async getBookingById(id: string, userId: string, carrierId?: string): Promise<CsvBookingResponseDto> {
async getBookingById(
id: string,
userId: string,
carrierId?: string
): Promise<CsvBookingResponseDto> {
const booking = await this.csvBookingRepository.findById(id);
if (!booking) {

View File

@ -7,9 +7,15 @@ import {
BadRequestException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InvitationTokenRepository, INVITATION_TOKEN_REPOSITORY } from '@domain/ports/out/invitation-token.repository';
import {
InvitationTokenRepository,
INVITATION_TOKEN_REPOSITORY,
} from '@domain/ports/out/invitation-token.repository';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { OrganizationRepository, ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import { InvitationToken } from '@domain/entities/invitation-token.entity';
import { UserRole } from '@domain/entities/user.entity';
@ -189,7 +195,10 @@ export class InvitationService {
this.logger.log(`[INVITATION] ✅ Email sent successfully to ${invitation.email}`);
} catch (error) {
this.logger.error(`[INVITATION] ❌ Failed to send invitation email to ${invitation.email}`, error);
this.logger.error(
`[INVITATION] ❌ Failed to send invitation email to ${invitation.email}`,
error
);
this.logger.error(`[INVITATION] Error details: ${JSON.stringify(error, null, 2)}`);
throw error;
}

View File

@ -17,7 +17,11 @@ export interface CsvRateLoaderPort {
* @returns Array of CSV rates
* @throws Error if file cannot be read or parsed
*/
loadRatesFromCsv(filePath: string, companyEmail: string, companyNameOverride?: string): Promise<CsvRate[]>;
loadRatesFromCsv(
filePath: string,
companyEmail: string,
companyNameOverride?: string
): Promise<CsvRate[]>;
/**
* Load rates for a specific company

View File

@ -177,9 +177,13 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
});
// Apply service level price adjustment to the total price
const adjustedTotalPrice = priceBreakdown.totalPrice *
(offer.serviceLevel === ServiceLevel.RAPID ? 1.20 :
offer.serviceLevel === ServiceLevel.ECONOMIC ? 0.85 : 1.00);
const adjustedTotalPrice =
priceBreakdown.totalPrice *
(offer.serviceLevel === ServiceLevel.RAPID
? 1.2
: offer.serviceLevel === ServiceLevel.ECONOMIC
? 0.85
: 1.0);
return {
rate: offer.rate,
@ -207,8 +211,8 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
// Apply service level filter if specified
let filteredResults = results;
if (input.filters?.serviceLevels && input.filters.serviceLevels.length > 0) {
filteredResults = results.filter(r =>
r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel)
filteredResults = results.filter(
r => r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel)
);
}
@ -252,14 +256,20 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
// Use allSettled to handle missing files gracefully
const results = await Promise.allSettled(ratePromises);
const rateArrays = results
.filter((result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled')
.filter(
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
)
.map(result => result.value);
// Log any failed file loads
const failures = results.filter(result => result.status === 'rejected');
if (failures.length > 0) {
console.warn(`Failed to load ${failures.length} CSV files:`,
failures.map((f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`));
console.warn(
`Failed to load ${failures.length} CSV files:`,
failures.map(
(f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`
)
);
}
return rateArrays.flat();
@ -274,7 +284,9 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
// Use allSettled here too for consistency
const results = await Promise.allSettled(ratePromises);
const rateArrays = results
.filter((result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled')
.filter(
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
)
.map(result => result.value);
return rateArrays.flat();

View File

@ -1,433 +1,433 @@
import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.service';
import { CsvRate } from '../entities/csv-rate.entity';
import { PortCode } from '../value-objects/port-code.vo';
import { ContainerType } from '../value-objects/container-type.vo';
import { Money } from '../value-objects/money.vo';
/**
* Test Suite for Rate Offer Generator Service
*
* Vérifie que:
* - RAPID est le plus cher ET le plus rapide
* - ECONOMIC est le moins cher ET le plus lent
* - STANDARD est au milieu en prix et transit time
*/
describe('RateOfferGeneratorService', () => {
let service: RateOfferGeneratorService;
let mockRate: CsvRate;
beforeEach(() => {
service = new RateOfferGeneratorService();
// Créer un tarif de base pour les tests
// Prix: 1000 USD / 900 EUR, Transit: 20 jours
mockRate = {
companyName: 'Test Carrier',
companyEmail: 'test@carrier.com',
origin: PortCode.create('FRPAR'),
destination: PortCode.create('USNYC'),
containerType: ContainerType.create('LCL'),
volumeRange: { minCBM: 1, maxCBM: 10 },
weightRange: { minKG: 100, maxKG: 5000 },
palletCount: 0,
pricing: {
pricePerCBM: 100,
pricePerKG: 0.5,
basePriceUSD: Money.create(1000, 'USD'),
basePriceEUR: Money.create(900, 'EUR'),
},
currency: 'USD',
hasSurcharges: false,
surchargeBAF: null,
surchargeCAF: null,
surchargeDetails: null,
transitDays: 20,
validity: {
getStartDate: () => new Date('2024-01-01'),
getEndDate: () => new Date('2024-12-31'),
},
isValidForDate: () => true,
matchesRoute: () => true,
matchesVolume: () => true,
matchesPalletCount: () => true,
getPriceInCurrency: () => Money.create(1000, 'USD'),
isAllInPrice: () => true,
getSurchargeDetails: () => null,
} as any;
});
describe('generateOffers', () => {
it('devrait générer exactement 3 offres (RAPID, STANDARD, ECONOMIC)', () => {
const offers = service.generateOffers(mockRate);
expect(offers).toHaveLength(3);
expect(offers.map(o => o.serviceLevel)).toEqual(
expect.arrayContaining([ServiceLevel.RAPID, ServiceLevel.STANDARD, ServiceLevel.ECONOMIC])
);
});
it('ECONOMIC doit être le moins cher', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// ECONOMIC doit avoir le prix le plus bas
expect(economic!.adjustedPriceUSD).toBeLessThan(standard!.adjustedPriceUSD);
expect(economic!.adjustedPriceUSD).toBeLessThan(rapid!.adjustedPriceUSD);
// Vérifier le prix attendu: 1000 * 0.85 = 850 USD
expect(economic!.adjustedPriceUSD).toBe(850);
expect(economic!.priceAdjustmentPercent).toBe(-15);
});
it('RAPID doit être le plus cher', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// RAPID doit avoir le prix le plus élevé
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(standard!.adjustedPriceUSD);
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(economic!.adjustedPriceUSD);
// Vérifier le prix attendu: 1000 * 1.20 = 1200 USD
expect(rapid!.adjustedPriceUSD).toBe(1200);
expect(rapid!.priceAdjustmentPercent).toBe(20);
});
it('STANDARD doit avoir le prix de base (pas d\'ajustement)', () => {
const offers = service.generateOffers(mockRate);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
// STANDARD doit avoir le prix de base (pas de changement)
expect(standard!.adjustedPriceUSD).toBe(1000);
expect(standard!.adjustedPriceEUR).toBe(900);
expect(standard!.priceAdjustmentPercent).toBe(0);
});
it('RAPID doit être le plus rapide (moins de jours de transit)', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// RAPID doit avoir le transit time le plus court
expect(rapid!.adjustedTransitDays).toBeLessThan(standard!.adjustedTransitDays);
expect(rapid!.adjustedTransitDays).toBeLessThan(economic!.adjustedTransitDays);
// Vérifier le transit attendu: 20 * 0.70 = 14 jours
expect(rapid!.adjustedTransitDays).toBe(14);
expect(rapid!.transitAdjustmentPercent).toBe(-30);
});
it('ECONOMIC doit être le plus lent (plus de jours de transit)', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// ECONOMIC doit avoir le transit time le plus long
expect(economic!.adjustedTransitDays).toBeGreaterThan(standard!.adjustedTransitDays);
expect(economic!.adjustedTransitDays).toBeGreaterThan(rapid!.adjustedTransitDays);
// Vérifier le transit attendu: 20 * 1.50 = 30 jours
expect(economic!.adjustedTransitDays).toBe(30);
expect(economic!.transitAdjustmentPercent).toBe(50);
});
it('STANDARD doit avoir le transit time de base (pas d\'ajustement)', () => {
const offers = service.generateOffers(mockRate);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
// STANDARD doit avoir le transit time de base
expect(standard!.adjustedTransitDays).toBe(20);
expect(standard!.transitAdjustmentPercent).toBe(0);
});
it('les offres doivent être triées par prix croissant (ECONOMIC -> STANDARD -> RAPID)', () => {
const offers = service.generateOffers(mockRate);
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
expect(offers[1].serviceLevel).toBe(ServiceLevel.STANDARD);
expect(offers[2].serviceLevel).toBe(ServiceLevel.RAPID);
// Vérifier que les prix sont dans l'ordre croissant
expect(offers[0].adjustedPriceUSD).toBeLessThan(offers[1].adjustedPriceUSD);
expect(offers[1].adjustedPriceUSD).toBeLessThan(offers[2].adjustedPriceUSD);
});
it('doit conserver les informations originales du tarif', () => {
const offers = service.generateOffers(mockRate);
for (const offer of offers) {
expect(offer.rate).toBe(mockRate);
expect(offer.originalPriceUSD).toBe(1000);
expect(offer.originalPriceEUR).toBe(900);
expect(offer.originalTransitDays).toBe(20);
}
});
it('doit appliquer la contrainte de transit time minimum (5 jours)', () => {
// Tarif avec transit time très court (3 jours)
const shortTransitRate = {
...mockRate,
transitDays: 3,
} as any;
const offers = service.generateOffers(shortTransitRate);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// RAPID avec 3 * 0.70 = 2.1 jours, mais minimum est 5 jours
expect(rapid!.adjustedTransitDays).toBe(5);
});
it('doit appliquer la contrainte de transit time maximum (90 jours)', () => {
// Tarif avec transit time très long (80 jours)
const longTransitRate = {
...mockRate,
transitDays: 80,
} as any;
const offers = service.generateOffers(longTransitRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
// ECONOMIC avec 80 * 1.50 = 120 jours, mais maximum est 90 jours
expect(economic!.adjustedTransitDays).toBe(90);
});
});
describe('generateOffersForRates', () => {
it('doit générer 3 offres par tarif', () => {
const rate1 = mockRate;
const rate2 = {
...mockRate,
companyName: 'Another Carrier',
} as any;
const offers = service.generateOffersForRates([rate1, rate2]);
expect(offers).toHaveLength(6); // 2 tarifs * 3 offres
});
it('doit trier toutes les offres par prix croissant', () => {
const rate1 = mockRate; // Prix base: 1000 USD
const rate2 = {
...mockRate,
companyName: 'Cheaper Carrier',
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(500, 'USD'), // Prix base plus bas
},
} as any;
const offers = service.generateOffersForRates([rate1, rate2]);
// Vérifier que les prix sont triés
for (let i = 0; i < offers.length - 1; i++) {
expect(offers[i].adjustedPriceUSD).toBeLessThanOrEqual(offers[i + 1].adjustedPriceUSD);
}
// L'offre la moins chère devrait être ECONOMIC du rate2
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
expect(offers[0].rate.companyName).toBe('Cheaper Carrier');
});
});
describe('generateOffersForServiceLevel', () => {
it('doit générer uniquement les offres RAPID', () => {
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.RAPID);
expect(offers).toHaveLength(1);
expect(offers[0].serviceLevel).toBe(ServiceLevel.RAPID);
});
it('doit générer uniquement les offres ECONOMIC', () => {
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.ECONOMIC);
expect(offers).toHaveLength(1);
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
});
});
describe('getCheapestOffer', () => {
it('doit retourner l\'offre ECONOMIC la moins chère', () => {
const rate1 = mockRate; // 1000 USD base
const rate2 = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(500, 'USD'),
},
} as any;
const cheapest = service.getCheapestOffer([rate1, rate2]);
expect(cheapest).not.toBeNull();
expect(cheapest!.serviceLevel).toBe(ServiceLevel.ECONOMIC);
// 500 * 0.85 = 425 USD
expect(cheapest!.adjustedPriceUSD).toBe(425);
});
it('doit retourner null si aucun tarif', () => {
const cheapest = service.getCheapestOffer([]);
expect(cheapest).toBeNull();
});
});
describe('getFastestOffer', () => {
it('doit retourner l\'offre RAPID la plus rapide', () => {
const rate1 = { ...mockRate, transitDays: 20 } as any;
const rate2 = { ...mockRate, transitDays: 10 } as any;
const fastest = service.getFastestOffer([rate1, rate2]);
expect(fastest).not.toBeNull();
expect(fastest!.serviceLevel).toBe(ServiceLevel.RAPID);
// 10 * 0.70 = 7 jours
expect(fastest!.adjustedTransitDays).toBe(7);
});
it('doit retourner null si aucun tarif', () => {
const fastest = service.getFastestOffer([]);
expect(fastest).toBeNull();
});
});
describe('getBestOffersPerServiceLevel', () => {
it('doit retourner la meilleure offre de chaque niveau de service', () => {
const rate1 = mockRate;
const rate2 = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(800, 'USD'),
},
} as any;
const best = service.getBestOffersPerServiceLevel([rate1, rate2]);
expect(best.rapid).not.toBeNull();
expect(best.standard).not.toBeNull();
expect(best.economic).not.toBeNull();
// Toutes doivent provenir du rate2 (moins cher)
expect(best.rapid!.originalPriceUSD).toBe(800);
expect(best.standard!.originalPriceUSD).toBe(800);
expect(best.economic!.originalPriceUSD).toBe(800);
});
});
describe('isRateEligible', () => {
it('doit accepter un tarif valide', () => {
expect(service.isRateEligible(mockRate)).toBe(true);
});
it('doit rejeter un tarif avec transit time = 0', () => {
const invalidRate = { ...mockRate, transitDays: 0 } as any;
expect(service.isRateEligible(invalidRate)).toBe(false);
});
it('doit rejeter un tarif avec prix = 0', () => {
const invalidRate = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(0, 'USD'),
},
} as any;
expect(service.isRateEligible(invalidRate)).toBe(false);
});
it('doit rejeter un tarif expiré', () => {
const expiredRate = {
...mockRate,
isValidForDate: () => false,
} as any;
expect(service.isRateEligible(expiredRate)).toBe(false);
});
});
describe('filterEligibleRates', () => {
it('doit filtrer les tarifs invalides', () => {
const validRate = mockRate;
const invalidRate1 = { ...mockRate, transitDays: 0 } as any;
const invalidRate2 = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(0, 'USD'),
},
} as any;
const eligibleRates = service.filterEligibleRates([validRate, invalidRate1, invalidRate2]);
expect(eligibleRates).toHaveLength(1);
expect(eligibleRates[0]).toBe(validRate);
});
});
describe('Validation de la logique métier', () => {
it('RAPID doit TOUJOURS être plus cher que ECONOMIC', () => {
// Test avec différents prix de base
const prices = [100, 500, 1000, 5000, 10000];
for (const price of prices) {
const rate = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(price, 'USD'),
},
} as any;
const offers = service.generateOffers(rate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
}
});
it('RAPID doit TOUJOURS être plus rapide que ECONOMIC', () => {
// Test avec différents transit times de base
const transitDays = [5, 10, 20, 30, 60];
for (const days of transitDays) {
const rate = { ...mockRate, transitDays: days } as any;
const offers = service.generateOffers(rate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
}
});
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le prix', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(standard.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
expect(standard.adjustedPriceUSD).toBeLessThan(rapid.adjustedPriceUSD);
});
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le transit time', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(standard.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
expect(standard.adjustedTransitDays).toBeGreaterThan(rapid.adjustedTransitDays);
});
});
});
import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.service';
import { CsvRate } from '../entities/csv-rate.entity';
import { PortCode } from '../value-objects/port-code.vo';
import { ContainerType } from '../value-objects/container-type.vo';
import { Money } from '../value-objects/money.vo';
/**
* Test Suite for Rate Offer Generator Service
*
* Vérifie que:
* - RAPID est le plus cher ET le plus rapide
* - ECONOMIC est le moins cher ET le plus lent
* - STANDARD est au milieu en prix et transit time
*/
describe('RateOfferGeneratorService', () => {
let service: RateOfferGeneratorService;
let mockRate: CsvRate;
beforeEach(() => {
service = new RateOfferGeneratorService();
// Créer un tarif de base pour les tests
// Prix: 1000 USD / 900 EUR, Transit: 20 jours
mockRate = {
companyName: 'Test Carrier',
companyEmail: 'test@carrier.com',
origin: PortCode.create('FRPAR'),
destination: PortCode.create('USNYC'),
containerType: ContainerType.create('LCL'),
volumeRange: { minCBM: 1, maxCBM: 10 },
weightRange: { minKG: 100, maxKG: 5000 },
palletCount: 0,
pricing: {
pricePerCBM: 100,
pricePerKG: 0.5,
basePriceUSD: Money.create(1000, 'USD'),
basePriceEUR: Money.create(900, 'EUR'),
},
currency: 'USD',
hasSurcharges: false,
surchargeBAF: null,
surchargeCAF: null,
surchargeDetails: null,
transitDays: 20,
validity: {
getStartDate: () => new Date('2024-01-01'),
getEndDate: () => new Date('2024-12-31'),
},
isValidForDate: () => true,
matchesRoute: () => true,
matchesVolume: () => true,
matchesPalletCount: () => true,
getPriceInCurrency: () => Money.create(1000, 'USD'),
isAllInPrice: () => true,
getSurchargeDetails: () => null,
} as any;
});
describe('generateOffers', () => {
it('devrait générer exactement 3 offres (RAPID, STANDARD, ECONOMIC)', () => {
const offers = service.generateOffers(mockRate);
expect(offers).toHaveLength(3);
expect(offers.map(o => o.serviceLevel)).toEqual(
expect.arrayContaining([ServiceLevel.RAPID, ServiceLevel.STANDARD, ServiceLevel.ECONOMIC])
);
});
it('ECONOMIC doit être le moins cher', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// ECONOMIC doit avoir le prix le plus bas
expect(economic!.adjustedPriceUSD).toBeLessThan(standard!.adjustedPriceUSD);
expect(economic!.adjustedPriceUSD).toBeLessThan(rapid!.adjustedPriceUSD);
// Vérifier le prix attendu: 1000 * 0.85 = 850 USD
expect(economic!.adjustedPriceUSD).toBe(850);
expect(economic!.priceAdjustmentPercent).toBe(-15);
});
it('RAPID doit être le plus cher', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// RAPID doit avoir le prix le plus élevé
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(standard!.adjustedPriceUSD);
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(economic!.adjustedPriceUSD);
// Vérifier le prix attendu: 1000 * 1.20 = 1200 USD
expect(rapid!.adjustedPriceUSD).toBe(1200);
expect(rapid!.priceAdjustmentPercent).toBe(20);
});
it("STANDARD doit avoir le prix de base (pas d'ajustement)", () => {
const offers = service.generateOffers(mockRate);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
// STANDARD doit avoir le prix de base (pas de changement)
expect(standard!.adjustedPriceUSD).toBe(1000);
expect(standard!.adjustedPriceEUR).toBe(900);
expect(standard!.priceAdjustmentPercent).toBe(0);
});
it('RAPID doit être le plus rapide (moins de jours de transit)', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// RAPID doit avoir le transit time le plus court
expect(rapid!.adjustedTransitDays).toBeLessThan(standard!.adjustedTransitDays);
expect(rapid!.adjustedTransitDays).toBeLessThan(economic!.adjustedTransitDays);
// Vérifier le transit attendu: 20 * 0.70 = 14 jours
expect(rapid!.adjustedTransitDays).toBe(14);
expect(rapid!.transitAdjustmentPercent).toBe(-30);
});
it('ECONOMIC doit être le plus lent (plus de jours de transit)', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// ECONOMIC doit avoir le transit time le plus long
expect(economic!.adjustedTransitDays).toBeGreaterThan(standard!.adjustedTransitDays);
expect(economic!.adjustedTransitDays).toBeGreaterThan(rapid!.adjustedTransitDays);
// Vérifier le transit attendu: 20 * 1.50 = 30 jours
expect(economic!.adjustedTransitDays).toBe(30);
expect(economic!.transitAdjustmentPercent).toBe(50);
});
it("STANDARD doit avoir le transit time de base (pas d'ajustement)", () => {
const offers = service.generateOffers(mockRate);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
// STANDARD doit avoir le transit time de base
expect(standard!.adjustedTransitDays).toBe(20);
expect(standard!.transitAdjustmentPercent).toBe(0);
});
it('les offres doivent être triées par prix croissant (ECONOMIC -> STANDARD -> RAPID)', () => {
const offers = service.generateOffers(mockRate);
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
expect(offers[1].serviceLevel).toBe(ServiceLevel.STANDARD);
expect(offers[2].serviceLevel).toBe(ServiceLevel.RAPID);
// Vérifier que les prix sont dans l'ordre croissant
expect(offers[0].adjustedPriceUSD).toBeLessThan(offers[1].adjustedPriceUSD);
expect(offers[1].adjustedPriceUSD).toBeLessThan(offers[2].adjustedPriceUSD);
});
it('doit conserver les informations originales du tarif', () => {
const offers = service.generateOffers(mockRate);
for (const offer of offers) {
expect(offer.rate).toBe(mockRate);
expect(offer.originalPriceUSD).toBe(1000);
expect(offer.originalPriceEUR).toBe(900);
expect(offer.originalTransitDays).toBe(20);
}
});
it('doit appliquer la contrainte de transit time minimum (5 jours)', () => {
// Tarif avec transit time très court (3 jours)
const shortTransitRate = {
...mockRate,
transitDays: 3,
} as any;
const offers = service.generateOffers(shortTransitRate);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// RAPID avec 3 * 0.70 = 2.1 jours, mais minimum est 5 jours
expect(rapid!.adjustedTransitDays).toBe(5);
});
it('doit appliquer la contrainte de transit time maximum (90 jours)', () => {
// Tarif avec transit time très long (80 jours)
const longTransitRate = {
...mockRate,
transitDays: 80,
} as any;
const offers = service.generateOffers(longTransitRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
// ECONOMIC avec 80 * 1.50 = 120 jours, mais maximum est 90 jours
expect(economic!.adjustedTransitDays).toBe(90);
});
});
describe('generateOffersForRates', () => {
it('doit générer 3 offres par tarif', () => {
const rate1 = mockRate;
const rate2 = {
...mockRate,
companyName: 'Another Carrier',
} as any;
const offers = service.generateOffersForRates([rate1, rate2]);
expect(offers).toHaveLength(6); // 2 tarifs * 3 offres
});
it('doit trier toutes les offres par prix croissant', () => {
const rate1 = mockRate; // Prix base: 1000 USD
const rate2 = {
...mockRate,
companyName: 'Cheaper Carrier',
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(500, 'USD'), // Prix base plus bas
},
} as any;
const offers = service.generateOffersForRates([rate1, rate2]);
// Vérifier que les prix sont triés
for (let i = 0; i < offers.length - 1; i++) {
expect(offers[i].adjustedPriceUSD).toBeLessThanOrEqual(offers[i + 1].adjustedPriceUSD);
}
// L'offre la moins chère devrait être ECONOMIC du rate2
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
expect(offers[0].rate.companyName).toBe('Cheaper Carrier');
});
});
describe('generateOffersForServiceLevel', () => {
it('doit générer uniquement les offres RAPID', () => {
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.RAPID);
expect(offers).toHaveLength(1);
expect(offers[0].serviceLevel).toBe(ServiceLevel.RAPID);
});
it('doit générer uniquement les offres ECONOMIC', () => {
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.ECONOMIC);
expect(offers).toHaveLength(1);
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
});
});
describe('getCheapestOffer', () => {
it("doit retourner l'offre ECONOMIC la moins chère", () => {
const rate1 = mockRate; // 1000 USD base
const rate2 = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(500, 'USD'),
},
} as any;
const cheapest = service.getCheapestOffer([rate1, rate2]);
expect(cheapest).not.toBeNull();
expect(cheapest!.serviceLevel).toBe(ServiceLevel.ECONOMIC);
// 500 * 0.85 = 425 USD
expect(cheapest!.adjustedPriceUSD).toBe(425);
});
it('doit retourner null si aucun tarif', () => {
const cheapest = service.getCheapestOffer([]);
expect(cheapest).toBeNull();
});
});
describe('getFastestOffer', () => {
it("doit retourner l'offre RAPID la plus rapide", () => {
const rate1 = { ...mockRate, transitDays: 20 } as any;
const rate2 = { ...mockRate, transitDays: 10 } as any;
const fastest = service.getFastestOffer([rate1, rate2]);
expect(fastest).not.toBeNull();
expect(fastest!.serviceLevel).toBe(ServiceLevel.RAPID);
// 10 * 0.70 = 7 jours
expect(fastest!.adjustedTransitDays).toBe(7);
});
it('doit retourner null si aucun tarif', () => {
const fastest = service.getFastestOffer([]);
expect(fastest).toBeNull();
});
});
describe('getBestOffersPerServiceLevel', () => {
it('doit retourner la meilleure offre de chaque niveau de service', () => {
const rate1 = mockRate;
const rate2 = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(800, 'USD'),
},
} as any;
const best = service.getBestOffersPerServiceLevel([rate1, rate2]);
expect(best.rapid).not.toBeNull();
expect(best.standard).not.toBeNull();
expect(best.economic).not.toBeNull();
// Toutes doivent provenir du rate2 (moins cher)
expect(best.rapid!.originalPriceUSD).toBe(800);
expect(best.standard!.originalPriceUSD).toBe(800);
expect(best.economic!.originalPriceUSD).toBe(800);
});
});
describe('isRateEligible', () => {
it('doit accepter un tarif valide', () => {
expect(service.isRateEligible(mockRate)).toBe(true);
});
it('doit rejeter un tarif avec transit time = 0', () => {
const invalidRate = { ...mockRate, transitDays: 0 } as any;
expect(service.isRateEligible(invalidRate)).toBe(false);
});
it('doit rejeter un tarif avec prix = 0', () => {
const invalidRate = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(0, 'USD'),
},
} as any;
expect(service.isRateEligible(invalidRate)).toBe(false);
});
it('doit rejeter un tarif expiré', () => {
const expiredRate = {
...mockRate,
isValidForDate: () => false,
} as any;
expect(service.isRateEligible(expiredRate)).toBe(false);
});
});
describe('filterEligibleRates', () => {
it('doit filtrer les tarifs invalides', () => {
const validRate = mockRate;
const invalidRate1 = { ...mockRate, transitDays: 0 } as any;
const invalidRate2 = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(0, 'USD'),
},
} as any;
const eligibleRates = service.filterEligibleRates([validRate, invalidRate1, invalidRate2]);
expect(eligibleRates).toHaveLength(1);
expect(eligibleRates[0]).toBe(validRate);
});
});
describe('Validation de la logique métier', () => {
it('RAPID doit TOUJOURS être plus cher que ECONOMIC', () => {
// Test avec différents prix de base
const prices = [100, 500, 1000, 5000, 10000];
for (const price of prices) {
const rate = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(price, 'USD'),
},
} as any;
const offers = service.generateOffers(rate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
}
});
it('RAPID doit TOUJOURS être plus rapide que ECONOMIC', () => {
// Test avec différents transit times de base
const transitDays = [5, 10, 20, 30, 60];
for (const days of transitDays) {
const rate = { ...mockRate, transitDays: days } as any;
const offers = service.generateOffers(rate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
}
});
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le prix', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(standard.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
expect(standard.adjustedPriceUSD).toBeLessThan(rapid.adjustedPriceUSD);
});
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le transit time', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(standard.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
expect(standard.adjustedTransitDays).toBeGreaterThan(rapid.adjustedTransitDays);
});
});
});

View File

@ -1,257 +1,255 @@
import { CsvRate } from '../entities/csv-rate.entity';
/**
* Service Level Types
*
* - RAPID: Offre la plus chère + la plus rapide (transit time réduit)
* - STANDARD: Offre standard (prix et transit time de base)
* - ECONOMIC: Offre la moins chère + la plus lente (transit time augmenté)
*/
export enum ServiceLevel {
RAPID = 'RAPID',
STANDARD = 'STANDARD',
ECONOMIC = 'ECONOMIC',
}
/**
* Rate Offer - Variante d'un tarif avec un niveau de service
*/
export interface RateOffer {
rate: CsvRate;
serviceLevel: ServiceLevel;
adjustedPriceUSD: number;
adjustedPriceEUR: number;
adjustedTransitDays: number;
originalPriceUSD: number;
originalPriceEUR: number;
originalTransitDays: number;
priceAdjustmentPercent: number;
transitAdjustmentPercent: number;
description: string;
}
/**
* Configuration pour les ajustements de prix et transit par niveau de service
*/
interface ServiceLevelConfig {
priceMultiplier: number; // Multiplicateur de prix (1.0 = pas de changement)
transitMultiplier: number; // Multiplicateur de transit time (1.0 = pas de changement)
description: string;
}
/**
* Rate Offer Generator Service
*
* Service du domaine qui génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV.
*
* Règles métier:
* - RAPID : Prix +20%, Transit -30% (plus cher, plus rapide)
* - STANDARD : Prix +0%, Transit +0% (tarif de base)
* - ECONOMIC : Prix -15%, Transit +50% (moins cher, plus lent)
*
* Pure domain logic - Pas de dépendances framework
*/
export class RateOfferGeneratorService {
/**
* Configuration par défaut des niveaux de service
* Ces valeurs peuvent être ajustées selon les besoins métier
*/
private readonly SERVICE_LEVEL_CONFIGS: Record<ServiceLevel, ServiceLevelConfig> = {
[ServiceLevel.RAPID]: {
priceMultiplier: 1.20, // +20% du prix de base
transitMultiplier: 0.70, // -30% du temps de transit (plus rapide)
description: 'Express - Livraison rapide avec service prioritaire',
},
[ServiceLevel.STANDARD]: {
priceMultiplier: 1.00, // Prix de base (pas de changement)
transitMultiplier: 1.00, // Transit time de base (pas de changement)
description: 'Standard - Service régulier au meilleur rapport qualité/prix',
},
[ServiceLevel.ECONOMIC]: {
priceMultiplier: 0.85, // -15% du prix de base
transitMultiplier: 1.50, // +50% du temps de transit (plus lent)
description: 'Économique - Tarif réduit avec délai étendu',
},
};
/**
* Transit time minimum (en jours) pour garantir la cohérence
* Même avec réduction, on ne peut pas descendre en dessous de ce minimum
*/
private readonly MIN_TRANSIT_DAYS = 5;
/**
* Transit time maximum (en jours) pour garantir la cohérence
* Même avec augmentation, on ne peut pas dépasser ce maximum
*/
private readonly MAX_TRANSIT_DAYS = 90;
/**
* Génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV
*
* @param rate - Le tarif CSV de base
* @returns Tableau de 3 offres triées par prix croissant (ECONOMIC, STANDARD, RAPID)
*/
generateOffers(rate: CsvRate): RateOffer[] {
const offers: RateOffer[] = [];
// Extraire les prix de base
const basePriceUSD = rate.pricing.basePriceUSD.getAmount();
const basePriceEUR = rate.pricing.basePriceEUR.getAmount();
const baseTransitDays = rate.transitDays;
// Générer les 3 offres
for (const serviceLevel of Object.values(ServiceLevel)) {
const config = this.SERVICE_LEVEL_CONFIGS[serviceLevel];
// Calculer les prix ajustés
const adjustedPriceUSD = this.roundPrice(basePriceUSD * config.priceMultiplier);
const adjustedPriceEUR = this.roundPrice(basePriceEUR * config.priceMultiplier);
// Calculer le transit time ajusté (avec contraintes min/max)
const rawTransitDays = baseTransitDays * config.transitMultiplier;
const adjustedTransitDays = this.constrainTransitDays(
Math.round(rawTransitDays)
);
// Calculer les pourcentages d'ajustement
const priceAdjustmentPercent = Math.round((config.priceMultiplier - 1) * 100);
const transitAdjustmentPercent = Math.round((config.transitMultiplier - 1) * 100);
offers.push({
rate,
serviceLevel,
adjustedPriceUSD,
adjustedPriceEUR,
adjustedTransitDays,
originalPriceUSD: basePriceUSD,
originalPriceEUR: basePriceEUR,
originalTransitDays: baseTransitDays,
priceAdjustmentPercent,
transitAdjustmentPercent,
description: config.description,
});
}
// Trier par prix croissant: ECONOMIC (moins cher) -> STANDARD -> RAPID (plus cher)
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
}
/**
* Génère plusieurs offres pour une liste de tarifs
*
* @param rates - Liste de tarifs CSV
* @returns Liste de toutes les offres générées (3 par tarif), triées par prix
*/
generateOffersForRates(rates: CsvRate[]): RateOffer[] {
const allOffers: RateOffer[] = [];
for (const rate of rates) {
const offers = this.generateOffers(rate);
allOffers.push(...offers);
}
// Trier toutes les offres par prix croissant
return allOffers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
}
/**
* Génère uniquement les offres d'un niveau de service spécifique
*
* @param rates - Liste de tarifs CSV
* @param serviceLevel - Niveau de service souhaité (RAPID, STANDARD, ECONOMIC)
* @returns Liste des offres du niveau de service demandé, triées par prix
*/
generateOffersForServiceLevel(rates: CsvRate[], serviceLevel: ServiceLevel): RateOffer[] {
const offers: RateOffer[] = [];
for (const rate of rates) {
const allOffers = this.generateOffers(rate);
const matchingOffer = allOffers.find(o => o.serviceLevel === serviceLevel);
if (matchingOffer) {
offers.push(matchingOffer);
}
}
// Trier par prix croissant
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
}
/**
* Obtient l'offre la moins chère (ECONOMIC) parmi une liste de tarifs
*/
getCheapestOffer(rates: CsvRate[]): RateOffer | null {
if (rates.length === 0) return null;
const economicOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC);
return economicOffers[0] || null;
}
/**
* Obtient l'offre la plus rapide (RAPID) parmi une liste de tarifs
*/
getFastestOffer(rates: CsvRate[]): RateOffer | null {
if (rates.length === 0) return null;
const rapidOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID);
// Trier par transit time croissant (plus rapide en premier)
rapidOffers.sort((a, b) => a.adjustedTransitDays - b.adjustedTransitDays);
return rapidOffers[0] || null;
}
/**
* Obtient les meilleures offres (meilleur rapport qualité/prix)
* Retourne une offre de chaque niveau de service avec le meilleur prix
*/
getBestOffersPerServiceLevel(rates: CsvRate[]): {
rapid: RateOffer | null;
standard: RateOffer | null;
economic: RateOffer | null;
} {
return {
rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] || null,
standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] || null,
economic: this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC)[0] || null,
};
}
/**
* Arrondit le prix à 2 décimales
*/
private roundPrice(price: number): number {
return Math.round(price * 100) / 100;
}
/**
* Contraint le transit time entre les limites min et max
*/
private constrainTransitDays(days: number): number {
return Math.max(this.MIN_TRANSIT_DAYS, Math.min(this.MAX_TRANSIT_DAYS, days));
}
/**
* Vérifie si un tarif est éligible pour la génération d'offres
*
* Critères:
* - Transit time doit être > 0
* - Prix doit être > 0
* - Tarif doit être valide (non expiré)
*/
isRateEligible(rate: CsvRate): boolean {
if (rate.transitDays <= 0) return false;
if (rate.pricing.basePriceUSD.getAmount() <= 0) return false;
if (!rate.isValidForDate(new Date())) return false;
return true;
}
/**
* Filtre les tarifs éligibles pour la génération d'offres
*/
filterEligibleRates(rates: CsvRate[]): CsvRate[] {
return rates.filter(rate => this.isRateEligible(rate));
}
}
import { CsvRate } from '../entities/csv-rate.entity';
/**
* Service Level Types
*
* - RAPID: Offre la plus chère + la plus rapide (transit time réduit)
* - STANDARD: Offre standard (prix et transit time de base)
* - ECONOMIC: Offre la moins chère + la plus lente (transit time augmenté)
*/
export enum ServiceLevel {
RAPID = 'RAPID',
STANDARD = 'STANDARD',
ECONOMIC = 'ECONOMIC',
}
/**
* Rate Offer - Variante d'un tarif avec un niveau de service
*/
export interface RateOffer {
rate: CsvRate;
serviceLevel: ServiceLevel;
adjustedPriceUSD: number;
adjustedPriceEUR: number;
adjustedTransitDays: number;
originalPriceUSD: number;
originalPriceEUR: number;
originalTransitDays: number;
priceAdjustmentPercent: number;
transitAdjustmentPercent: number;
description: string;
}
/**
* Configuration pour les ajustements de prix et transit par niveau de service
*/
interface ServiceLevelConfig {
priceMultiplier: number; // Multiplicateur de prix (1.0 = pas de changement)
transitMultiplier: number; // Multiplicateur de transit time (1.0 = pas de changement)
description: string;
}
/**
* Rate Offer Generator Service
*
* Service du domaine qui génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV.
*
* Règles métier:
* - RAPID : Prix +20%, Transit -30% (plus cher, plus rapide)
* - STANDARD : Prix +0%, Transit +0% (tarif de base)
* - ECONOMIC : Prix -15%, Transit +50% (moins cher, plus lent)
*
* Pure domain logic - Pas de dépendances framework
*/
export class RateOfferGeneratorService {
/**
* Configuration par défaut des niveaux de service
* Ces valeurs peuvent être ajustées selon les besoins métier
*/
private readonly SERVICE_LEVEL_CONFIGS: Record<ServiceLevel, ServiceLevelConfig> = {
[ServiceLevel.RAPID]: {
priceMultiplier: 1.2, // +20% du prix de base
transitMultiplier: 0.7, // -30% du temps de transit (plus rapide)
description: 'Express - Livraison rapide avec service prioritaire',
},
[ServiceLevel.STANDARD]: {
priceMultiplier: 1.0, // Prix de base (pas de changement)
transitMultiplier: 1.0, // Transit time de base (pas de changement)
description: 'Standard - Service régulier au meilleur rapport qualité/prix',
},
[ServiceLevel.ECONOMIC]: {
priceMultiplier: 0.85, // -15% du prix de base
transitMultiplier: 1.5, // +50% du temps de transit (plus lent)
description: 'Économique - Tarif réduit avec délai étendu',
},
};
/**
* Transit time minimum (en jours) pour garantir la cohérence
* Même avec réduction, on ne peut pas descendre en dessous de ce minimum
*/
private readonly MIN_TRANSIT_DAYS = 5;
/**
* Transit time maximum (en jours) pour garantir la cohérence
* Même avec augmentation, on ne peut pas dépasser ce maximum
*/
private readonly MAX_TRANSIT_DAYS = 90;
/**
* Génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV
*
* @param rate - Le tarif CSV de base
* @returns Tableau de 3 offres triées par prix croissant (ECONOMIC, STANDARD, RAPID)
*/
generateOffers(rate: CsvRate): RateOffer[] {
const offers: RateOffer[] = [];
// Extraire les prix de base
const basePriceUSD = rate.pricing.basePriceUSD.getAmount();
const basePriceEUR = rate.pricing.basePriceEUR.getAmount();
const baseTransitDays = rate.transitDays;
// Générer les 3 offres
for (const serviceLevel of Object.values(ServiceLevel)) {
const config = this.SERVICE_LEVEL_CONFIGS[serviceLevel];
// Calculer les prix ajustés
const adjustedPriceUSD = this.roundPrice(basePriceUSD * config.priceMultiplier);
const adjustedPriceEUR = this.roundPrice(basePriceEUR * config.priceMultiplier);
// Calculer le transit time ajusté (avec contraintes min/max)
const rawTransitDays = baseTransitDays * config.transitMultiplier;
const adjustedTransitDays = this.constrainTransitDays(Math.round(rawTransitDays));
// Calculer les pourcentages d'ajustement
const priceAdjustmentPercent = Math.round((config.priceMultiplier - 1) * 100);
const transitAdjustmentPercent = Math.round((config.transitMultiplier - 1) * 100);
offers.push({
rate,
serviceLevel,
adjustedPriceUSD,
adjustedPriceEUR,
adjustedTransitDays,
originalPriceUSD: basePriceUSD,
originalPriceEUR: basePriceEUR,
originalTransitDays: baseTransitDays,
priceAdjustmentPercent,
transitAdjustmentPercent,
description: config.description,
});
}
// Trier par prix croissant: ECONOMIC (moins cher) -> STANDARD -> RAPID (plus cher)
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
}
/**
* Génère plusieurs offres pour une liste de tarifs
*
* @param rates - Liste de tarifs CSV
* @returns Liste de toutes les offres générées (3 par tarif), triées par prix
*/
generateOffersForRates(rates: CsvRate[]): RateOffer[] {
const allOffers: RateOffer[] = [];
for (const rate of rates) {
const offers = this.generateOffers(rate);
allOffers.push(...offers);
}
// Trier toutes les offres par prix croissant
return allOffers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
}
/**
* Génère uniquement les offres d'un niveau de service spécifique
*
* @param rates - Liste de tarifs CSV
* @param serviceLevel - Niveau de service souhaité (RAPID, STANDARD, ECONOMIC)
* @returns Liste des offres du niveau de service demandé, triées par prix
*/
generateOffersForServiceLevel(rates: CsvRate[], serviceLevel: ServiceLevel): RateOffer[] {
const offers: RateOffer[] = [];
for (const rate of rates) {
const allOffers = this.generateOffers(rate);
const matchingOffer = allOffers.find(o => o.serviceLevel === serviceLevel);
if (matchingOffer) {
offers.push(matchingOffer);
}
}
// Trier par prix croissant
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
}
/**
* Obtient l'offre la moins chère (ECONOMIC) parmi une liste de tarifs
*/
getCheapestOffer(rates: CsvRate[]): RateOffer | null {
if (rates.length === 0) return null;
const economicOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC);
return economicOffers[0] || null;
}
/**
* Obtient l'offre la plus rapide (RAPID) parmi une liste de tarifs
*/
getFastestOffer(rates: CsvRate[]): RateOffer | null {
if (rates.length === 0) return null;
const rapidOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID);
// Trier par transit time croissant (plus rapide en premier)
rapidOffers.sort((a, b) => a.adjustedTransitDays - b.adjustedTransitDays);
return rapidOffers[0] || null;
}
/**
* Obtient les meilleures offres (meilleur rapport qualité/prix)
* Retourne une offre de chaque niveau de service avec le meilleur prix
*/
getBestOffersPerServiceLevel(rates: CsvRate[]): {
rapid: RateOffer | null;
standard: RateOffer | null;
economic: RateOffer | null;
} {
return {
rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] || null,
standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] || null,
economic: this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC)[0] || null,
};
}
/**
* Arrondit le prix à 2 décimales
*/
private roundPrice(price: number): number {
return Math.round(price * 100) / 100;
}
/**
* Contraint le transit time entre les limites min et max
*/
private constrainTransitDays(days: number): number {
return Math.max(this.MIN_TRANSIT_DAYS, Math.min(this.MAX_TRANSIT_DAYS, days));
}
/**
* Vérifie si un tarif est éligible pour la génération d'offres
*
* Critères:
* - Transit time doit être > 0
* - Prix doit être > 0
* - Tarif doit être valide (non expiré)
*/
isRateEligible(rate: CsvRate): boolean {
if (rate.transitDays <= 0) return false;
if (rate.pricing.basePriceUSD.getAmount() <= 0) return false;
if (!rate.isValidForDate(new Date())) return false;
return true;
}
/**
* Filtre les tarifs éligibles pour la génération d'offres
*/
filterEligibleRates(rates: CsvRate[]): CsvRate[] {
return rates.filter(rate => this.isRateEligible(rate));
}
}

View File

@ -90,8 +90,14 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
}
}
async loadRatesFromCsv(filePath: string, companyEmail: string, companyNameOverride?: string): Promise<CsvRate[]> {
this.logger.log(`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`);
async loadRatesFromCsv(
filePath: string,
companyEmail: string,
companyNameOverride?: string
): Promise<CsvRate[]> {
this.logger.log(
`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`
);
try {
let fileContent: string;
@ -114,7 +120,9 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
throw new Error('No MinIO object key found, using local file');
}
} catch (minioError: any) {
this.logger.warn(`⚠️ Failed to load from MinIO: ${minioError.message}. Falling back to local file.`);
this.logger.warn(
`⚠️ Failed to load from MinIO: ${minioError.message}. Falling back to local file.`
);
// Fallback to local file system
const fullPath = path.isAbsolute(filePath)
? filePath
@ -252,7 +260,9 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
this.logger.warn('⚠️ No CSV files configured in MinIO, falling back to local files');
}
} catch (minioError: any) {
this.logger.warn(`⚠️ Failed to list MinIO files: ${minioError.message}. Falling back to local files.`);
this.logger.warn(
`⚠️ Failed to list MinIO files: ${minioError.message}. Falling back to local files.`
);
}
}
@ -313,7 +323,11 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
/**
* Map CSV row to CsvRate domain entity
*/
private mapToCsvRate(record: CsvRow, companyEmail: string, companyNameOverride?: string): CsvRate {
private mapToCsvRate(
record: CsvRow,
companyEmail: string,
companyNameOverride?: string
): CsvRate {
// Parse surcharges
const surcharges = this.parseSurcharges(record);

View File

@ -42,7 +42,7 @@ export class CarrierActivityOrmEntity {
@Column({ name: 'carrier_id', type: 'uuid' })
carrierId: string;
@ManyToOne(() => CarrierProfileOrmEntity, (carrier) => carrier.activities, {
@ManyToOne(() => CarrierProfileOrmEntity, carrier => carrier.activities, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'carrier_id' })

View File

@ -118,9 +118,9 @@ export class CarrierProfileOrmEntity {
updatedAt: Date;
// Relations
@OneToMany(() => CsvBookingOrmEntity, (booking) => booking.carrierProfile)
@OneToMany(() => CsvBookingOrmEntity, booking => booking.carrierProfile)
bookings: CsvBookingOrmEntity[];
@OneToMany(() => CarrierActivityOrmEntity, (activity) => activity.carrierProfile)
@OneToMany(() => CarrierActivityOrmEntity, activity => activity.carrierProfile)
activities: CarrierActivityOrmEntity[];
}

View File

@ -113,7 +113,7 @@ export class CsvBookingOrmEntity {
@Column({ name: 'carrier_id', type: 'uuid', nullable: true })
carrierId: string | null;
@ManyToOne(() => CarrierProfileOrmEntity, (carrier) => carrier.bookings, {
@ManyToOne(() => CarrierProfileOrmEntity, carrier => carrier.bookings, {
onDelete: 'SET NULL',
})
@JoinColumn({ name: 'carrier_id' })

View File

@ -16,7 +16,8 @@ export class SeedTestUsers1730000000007 implements MigrationInterface {
// Pre-hashed password: Password123! (Argon2id)
// Generated with: argon2.hash('Password123!', { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 4 })
const passwordHash = '$argon2id$v=19$m=65536,t=3,p=4$Uj+yeQiaqgBFqyTJ5FX3Cw$wpRCYORyFwjQFSuO3gpmzh10gx9wjYFOCvVZ8TVaP8Q';
const passwordHash =
'$argon2id$v=19$m=65536,t=3,p=4$Uj+yeQiaqgBFqyTJ5FX3Cw$wpRCYORyFwjQFSuO3gpmzh10gx9wjYFOCvVZ8TVaP8Q';
// Fixed UUIDs for test users (matching existing data in database)
const users = [

View File

@ -7,7 +7,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CarrierActivityOrmEntity, CarrierActivityType } from '../entities/carrier-activity.orm-entity';
import {
CarrierActivityOrmEntity,
CarrierActivityType,
} from '../entities/carrier-activity.orm-entity';
@Injectable()
export class CarrierActivityRepository {
@ -27,7 +30,9 @@ export class CarrierActivityRepository {
ipAddress?: string | null;
userAgent?: string | null;
}): Promise<CarrierActivityOrmEntity> {
this.logger.log(`Creating carrier activity: ${data.activityType} for carrier ${data.carrierId}`);
this.logger.log(
`Creating carrier activity: ${data.activityType} for carrier ${data.carrierId}`
);
const activity = this.repository.create(data);
const saved = await this.repository.save(activity);
@ -36,7 +41,10 @@ export class CarrierActivityRepository {
return saved;
}
async findByCarrierId(carrierId: string, limit: number = 10): Promise<CarrierActivityOrmEntity[]> {
async findByCarrierId(
carrierId: string,
limit: number = 10
): Promise<CarrierActivityOrmEntity[]> {
this.logger.log(`Finding activities for carrier: ${carrierId} (limit: ${limit})`);
const activities = await this.repository.find({
@ -74,7 +82,9 @@ export class CarrierActivityRepository {
take: limit,
});
this.logger.log(`Found ${activities.length} ${activityType} activities for carrier: ${carrierId}`);
this.logger.log(
`Found ${activities.length} ${activityType} activities for carrier: ${carrierId}`
);
return activities;
}

View File

@ -76,7 +76,10 @@ export class CarrierProfileRepository {
return saved;
}
async update(id: string, data: Partial<CarrierProfileOrmEntity>): Promise<CarrierProfileOrmEntity> {
async update(
id: string,
data: Partial<CarrierProfileOrmEntity>
): Promise<CarrierProfileOrmEntity> {
this.logger.log(`Updating carrier profile: ${id}`);
await this.repository.update(id, data);
@ -131,7 +134,9 @@ export class CarrierProfileRepository {
relations: ['user', 'organization'],
});
this.logger.log(`Found ${profiles.length} carrier profiles for organization: ${organizationId}`);
this.logger.log(
`Found ${profiles.length} carrier profiles for organization: ${organizationId}`
);
return profiles;
}

View File

@ -1,42 +1,43 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
/**
* Script to delete orphaned CSV rate configuration
* Usage: npm run ts-node src/scripts/delete-orphaned-csv-config.ts
*/
async function deleteOrphanedConfig() {
const app = await NestFactory.createApplicationContext(AppModule);
const repository = app.get(TypeOrmCsvRateConfigRepository);
try {
console.log('🔍 Searching for orphaned test.csv configuration...');
const configs = await repository.findAll();
const orphanedConfig = configs.find((c) => c.csvFilePath === 'test.csv');
if (!orphanedConfig) {
console.log('✅ No orphaned test.csv configuration found');
await app.close();
return;
}
console.log(`📄 Found orphaned config: ${orphanedConfig.companyName} - ${orphanedConfig.csvFilePath}`);
console.log(` ID: ${orphanedConfig.id}`);
console.log(` Uploaded: ${orphanedConfig.uploadedAt}`);
// Delete the orphaned configuration
await repository.delete(orphanedConfig.companyName);
console.log('✅ Successfully deleted orphaned test.csv configuration');
} catch (error: any) {
console.error('❌ Error deleting orphaned config:', error.message);
process.exit(1);
}
await app.close();
}
deleteOrphanedConfig();
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
/**
* Script to delete orphaned CSV rate configuration
* Usage: npm run ts-node src/scripts/delete-orphaned-csv-config.ts
*/
async function deleteOrphanedConfig() {
const app = await NestFactory.createApplicationContext(AppModule);
const repository = app.get(TypeOrmCsvRateConfigRepository);
try {
console.log('🔍 Searching for orphaned test.csv configuration...');
const configs = await repository.findAll();
const orphanedConfig = configs.find(c => c.csvFilePath === 'test.csv');
if (!orphanedConfig) {
console.log('✅ No orphaned test.csv configuration found');
await app.close();
return;
}
console.log(
`📄 Found orphaned config: ${orphanedConfig.companyName} - ${orphanedConfig.csvFilePath}`
);
console.log(` ID: ${orphanedConfig.id}`);
console.log(` Uploaded: ${orphanedConfig.uploadedAt}`);
// Delete the orphaned configuration
await repository.delete(orphanedConfig.companyName);
console.log('✅ Successfully deleted orphaned test.csv configuration');
} catch (error: any) {
console.error('❌ Error deleting orphaned config:', error.message);
process.exit(1);
}
await app.close();
}
deleteOrphanedConfig();

View File

@ -1,118 +1,118 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter';
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
/**
* Script to migrate existing CSV files to MinIO
* Usage: npm run ts-node src/scripts/migrate-csv-to-minio.ts
*/
async function migrateCsvFilesToMinio() {
const app = await NestFactory.createApplicationContext(AppModule);
const s3Storage = app.get(S3StorageAdapter);
const csvConfigRepository = app.get(TypeOrmCsvRateConfigRepository);
const configService = app.get(ConfigService);
try {
console.log('🚀 Starting CSV migration to MinIO...\n');
const bucket = configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates');
const csvDirectory = path.join(
process.cwd(),
'src',
'infrastructure',
'storage',
'csv-storage',
'rates'
);
// Get all CSV configurations
const configs = await csvConfigRepository.findAll();
console.log(`📋 Found ${configs.length} CSV configurations\n`);
let migratedCount = 0;
let skippedCount = 0;
let errorCount = 0;
for (const config of configs) {
const filename = config.csvFilePath;
const filePath = path.join(csvDirectory, filename);
console.log(`📄 Processing: ${config.companyName} - ${filename}`);
// Check if already in MinIO
const existingMinioKey = config.metadata?.minioObjectKey as string | undefined;
if (existingMinioKey) {
console.log(` ⏭️ Already in MinIO: ${existingMinioKey}`);
skippedCount++;
continue;
}
// Check if file exists locally
if (!fs.existsSync(filePath)) {
console.log(` ⚠️ Local file not found: ${filePath}`);
errorCount++;
continue;
}
try {
// Read local file
const fileBuffer = fs.readFileSync(filePath);
const objectKey = `csv-rates/${filename}`;
// Upload to MinIO
await s3Storage.upload({
bucket,
key: objectKey,
body: fileBuffer,
contentType: 'text/csv',
metadata: {
companyName: config.companyName,
uploadedBy: 'migration-script',
migratedAt: new Date().toISOString(),
},
});
// Update configuration with MinIO object key
await csvConfigRepository.update(config.id, {
metadata: {
...config.metadata,
minioObjectKey: objectKey,
migratedToMinioAt: new Date().toISOString(),
},
});
console.log(` ✅ Uploaded to MinIO: ${bucket}/${objectKey}`);
migratedCount++;
} catch (error: any) {
console.log(` ❌ Error uploading ${filename}: ${error.message}`);
errorCount++;
}
}
console.log('\n' + '='.repeat(60));
console.log('📊 Migration Summary:');
console.log(` ✅ Migrated: ${migratedCount}`);
console.log(` ⏭️ Skipped (already in MinIO): ${skippedCount}`);
console.log(` ❌ Errors: ${errorCount}`);
console.log('='.repeat(60) + '\n');
if (migratedCount > 0) {
console.log('🎉 Migration completed successfully!');
} else if (skippedCount === configs.length) {
console.log('✅ All files are already in MinIO');
} else {
console.log('⚠️ Migration completed with errors');
}
} catch (error: any) {
console.error('❌ Migration failed:', error.message);
process.exit(1);
}
await app.close();
}
migrateCsvFilesToMinio();
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter';
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
/**
* Script to migrate existing CSV files to MinIO
* Usage: npm run ts-node src/scripts/migrate-csv-to-minio.ts
*/
async function migrateCsvFilesToMinio() {
const app = await NestFactory.createApplicationContext(AppModule);
const s3Storage = app.get(S3StorageAdapter);
const csvConfigRepository = app.get(TypeOrmCsvRateConfigRepository);
const configService = app.get(ConfigService);
try {
console.log('🚀 Starting CSV migration to MinIO...\n');
const bucket = configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates');
const csvDirectory = path.join(
process.cwd(),
'src',
'infrastructure',
'storage',
'csv-storage',
'rates'
);
// Get all CSV configurations
const configs = await csvConfigRepository.findAll();
console.log(`📋 Found ${configs.length} CSV configurations\n`);
let migratedCount = 0;
let skippedCount = 0;
let errorCount = 0;
for (const config of configs) {
const filename = config.csvFilePath;
const filePath = path.join(csvDirectory, filename);
console.log(`📄 Processing: ${config.companyName} - ${filename}`);
// Check if already in MinIO
const existingMinioKey = config.metadata?.minioObjectKey as string | undefined;
if (existingMinioKey) {
console.log(` ⏭️ Already in MinIO: ${existingMinioKey}`);
skippedCount++;
continue;
}
// Check if file exists locally
if (!fs.existsSync(filePath)) {
console.log(` ⚠️ Local file not found: ${filePath}`);
errorCount++;
continue;
}
try {
// Read local file
const fileBuffer = fs.readFileSync(filePath);
const objectKey = `csv-rates/${filename}`;
// Upload to MinIO
await s3Storage.upload({
bucket,
key: objectKey,
body: fileBuffer,
contentType: 'text/csv',
metadata: {
companyName: config.companyName,
uploadedBy: 'migration-script',
migratedAt: new Date().toISOString(),
},
});
// Update configuration with MinIO object key
await csvConfigRepository.update(config.id, {
metadata: {
...config.metadata,
minioObjectKey: objectKey,
migratedToMinioAt: new Date().toISOString(),
},
});
console.log(` ✅ Uploaded to MinIO: ${bucket}/${objectKey}`);
migratedCount++;
} catch (error: any) {
console.log(` ❌ Error uploading ${filename}: ${error.message}`);
errorCount++;
}
}
console.log('\n' + '='.repeat(60));
console.log('📊 Migration Summary:');
console.log(` ✅ Migrated: ${migratedCount}`);
console.log(` ⏭️ Skipped (already in MinIO): ${skippedCount}`);
console.log(` ❌ Errors: ${errorCount}`);
console.log('='.repeat(60) + '\n');
if (migratedCount > 0) {
console.log('🎉 Migration completed successfully!');
} else if (skippedCount === configs.length) {
console.log('✅ All files are already in MinIO');
} else {
console.log('⚠️ Migration completed with errors');
}
} catch (error: any) {
console.error('❌ Migration failed:', error.message);
process.exit(1);
}
await app.close();
}
migrateCsvFilesToMinio();

View File

@ -116,9 +116,7 @@ describe('Carrier Portal (e2e)', () => {
});
it('should return 401 without auth token', () => {
return request(app.getHttpServer())
.get('/api/v1/carrier-auth/me')
.expect(401);
return request(app.getHttpServer()).get('/api/v1/carrier-auth/me').expect(401);
});
});
@ -169,9 +167,7 @@ describe('Carrier Portal (e2e)', () => {
});
it('should return 401 without auth token', () => {
return request(app.getHttpServer())
.get('/api/v1/carrier-dashboard/stats')
.expect(401);
return request(app.getHttpServer()).get('/api/v1/carrier-dashboard/stats').expect(401);
});
});

View File

@ -1,390 +1,352 @@
/**
* Dashboard Home Page
*
* Main dashboard with KPIs, charts, and alerts
* Main dashboard with CSV Booking KPIs and carrier analytics
*/
'use client';
import { useQuery } from '@tanstack/react-query';
import { dashboardApi, listBookings } from '@/lib/api';
import { dashboardApi } from '@/lib/api';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
Package,
PackageCheck,
PackageX,
Clock,
Weight,
TrendingUp,
Plus,
ArrowRight,
} from 'lucide-react';
export default function DashboardPage() {
// Fetch dashboard data
const { data: kpis, isLoading: kpisLoading } = useQuery({
queryKey: ['dashboard', 'kpis'],
queryFn: () => dashboardApi.getKPIs(),
// Fetch CSV booking KPIs
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
queryKey: ['dashboard', 'csv-booking-kpis'],
queryFn: () => dashboardApi.getCsvBookingKPIs(),
});
const { data: chartData, isLoading: chartLoading } = useQuery({
queryKey: ['dashboard', 'bookings-chart'],
queryFn: () => dashboardApi.getBookingsChart(),
// Fetch top carriers
const { data: topCarriers, isLoading: carriersLoading } = useQuery({
queryKey: ['dashboard', 'top-carriers'],
queryFn: () => dashboardApi.getTopCarriers(),
});
const { data: tradeLanes, isLoading: tradeLanesLoading } = useQuery({
queryKey: ['dashboard', 'top-trade-lanes'],
queryFn: () => dashboardApi.getTopTradeLanes(),
});
const { data: alerts, isLoading: alertsLoading } = useQuery({
queryKey: ['dashboard', 'alerts'],
queryFn: () => dashboardApi.getAlerts(),
});
const { data: recentBookings, isLoading: bookingsLoading } = useQuery({
queryKey: ['bookings', 'recent'],
queryFn: () => listBookings({ limit: 5 }),
});
// Format chart data for Recharts
const formattedChartData = chartData
? chartData.labels.map((label, index) => ({
month: label,
bookings: chartData.data[index],
}))
: [];
// Format change percentage
const formatChange = (value: number) => {
const sign = value >= 0 ? '+' : '';
return `${sign}${value.toFixed(1)}%`;
};
// Get change color
const getChangeColor = (value: number) => {
if (value > 0) return 'text-green-600';
if (value < 0) return 'text-red-600';
return 'text-gray-600';
};
// Get alert color
const getAlertColor = (severity: string) => {
const colors = {
critical: 'bg-red-100 border-red-500 text-red-800',
high: 'bg-orange-100 border-orange-500 text-orange-800',
medium: 'bg-yellow-100 border-yellow-500 text-yellow-800',
low: 'bg-blue-100 border-blue-500 text-blue-800',
};
return colors[severity as keyof typeof colors] || colors.low;
};
return (
<div className="space-y-6">
{/* Welcome Section */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg shadow-lg p-6 text-white">
<h1 className="text-3xl font-bold mb-2">Welcome back!</h1>
<p className="text-blue-100">Here's what's happening with your shipments today.</p>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{kpisLoading ? (
// Loading skeletons
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-white rounded-lg shadow p-6 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4"></div>
<div className="h-8 bg-gray-200 rounded w-3/4"></div>
</div>
))
) : (
<>
<div className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Bookings This Month</p>
<p className="text-3xl font-bold text-gray-900 mt-2">
{kpis?.bookingsThisMonth || 0}
</p>
</div>
<div className="text-4xl">📦</div>
</div>
<div className="mt-4">
<span
className={`text-sm font-medium ${getChangeColor(
kpis?.bookingsThisMonthChange || 0
)}`}
>
{formatChange(kpis?.bookingsThisMonthChange || 0)}
</span>
<span className="text-sm text-gray-500 ml-2">vs last month</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total TEUs</p>
<p className="text-3xl font-bold text-gray-900 mt-2">{kpis?.totalTEUs || 0}</p>
</div>
<div className="text-4xl">📊</div>
</div>
<div className="mt-4">
<span
className={`text-sm font-medium ${getChangeColor(kpis?.totalTEUsChange || 0)}`}
>
{formatChange(kpis?.totalTEUsChange || 0)}
</span>
<span className="text-sm text-gray-500 ml-2">vs last month</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Estimated Revenue</p>
<p className="text-3xl font-bold text-gray-900 mt-2">
${(kpis?.estimatedRevenue || 0).toLocaleString()}
</p>
</div>
<div className="text-4xl">💰</div>
</div>
<div className="mt-4">
<span
className={`text-sm font-medium ${getChangeColor(
kpis?.estimatedRevenueChange || 0
)}`}
>
{formatChange(kpis?.estimatedRevenueChange || 0)}
</span>
<span className="text-sm text-gray-500 ml-2">vs last month</span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Pending Confirmations</p>
<p className="text-3xl font-bold text-gray-900 mt-2">
{kpis?.pendingConfirmations || 0}
</p>
</div>
<div className="text-4xl"></div>
</div>
<div className="mt-4">
<span
className={`text-sm font-medium ${getChangeColor(
kpis?.pendingConfirmationsChange || 0
)}`}
>
{formatChange(kpis?.pendingConfirmationsChange || 0)}
</span>
<span className="text-sm text-gray-500 ml-2">vs last month</span>
</div>
</div>
</>
)}
</div>
{/* Alerts Section */}
{!alertsLoading && alerts && alerts.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4"> Alerts & Notifications</h2>
<div className="space-y-3">
{alerts.slice(0, 5).map(alert => (
<div
key={alert.id}
className={`border-l-4 p-4 rounded ${getAlertColor(alert.severity)}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold">{alert.title}</h3>
<p className="text-sm mt-1">{alert.message}</p>
{alert.bookingNumber && (
<Link
href={`/dashboard/bookings/${alert.bookingId}`}
className="text-sm font-medium underline mt-2 inline-block"
>
View Booking {alert.bookingNumber}
</Link>
)}
</div>
<span className="text-xs font-medium uppercase ml-4">{alert.severity}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Bookings Trend Chart */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Bookings Trend (6 Months)</h2>
{chartLoading ? (
<div className="h-64 bg-gray-100 animate-pulse rounded"></div>
) : (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={formattedChartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="bookings" stroke="#3b82f6" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
)}
<div className="space-y-8 p-8">
{/* Header Section */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground mt-2">
Suivez vos bookings et vos performances
</p>
</div>
{/* Top Trade Lanes Chart */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Top 5 Trade Lanes</h2>
{tradeLanesLoading ? (
<div className="h-64 bg-gray-100 animate-pulse rounded"></div>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={tradeLanes}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="route" angle={-45} textAnchor="end" height={100} />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="bookingCount" fill="#3b82f6" name="Bookings" />
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Link
href="/dashboard/search"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow group"
>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center text-2xl group-hover:bg-blue-200 transition-colors">
🔍
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Search Rates</h3>
<p className="text-sm text-gray-500">Find the best shipping rates</p>
</div>
</div>
</Link>
<Link
href="/dashboard/bookings/new"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow group"
>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center text-2xl group-hover:bg-green-200 transition-colors">
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">New Booking</h3>
<p className="text-sm text-gray-500">Create a new shipment</p>
</div>
</div>
</Link>
<Link
href="/dashboard/bookings"
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow group"
>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center text-2xl group-hover:bg-purple-200 transition-colors">
📋
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">My Bookings</h3>
<p className="text-sm text-gray-500">View all your shipments</p>
</div>
</div>
{/* CTA Button */}
<Link href="/dashboard/bookings/new">
<Button size="lg" className="gap-2">
<Plus className="h-5 w-5" />
Nouveau Booking
</Button>
</Link>
</div>
{/* Recent Bookings */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Recent Bookings</h2>
<Link href="/dashboard/bookings" className="text-sm text-blue-600 hover:text-blue-800">
View All
</Link>
</div>
<div className="p-6">
{bookingsLoading ? (
{/* KPI Cards Grid */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{/* Bookings Acceptés */}
<Card className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Bookings Acceptés</CardTitle>
<PackageCheck className="h-5 w-5 text-green-600" />
</CardHeader>
<CardContent>
{csvKpisLoading ? (
<div className="h-8 w-20 bg-gray-200 animate-pulse rounded" />
) : (
<>
<div className="text-3xl font-bold">{csvKpis?.totalAccepted || 0}</div>
<p className="text-xs text-muted-foreground mt-2">
+{csvKpis?.acceptedThisMonth || 0} ce mois
</p>
</>
)}
</CardContent>
</Card>
{/* Bookings Refusés */}
<Card className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Bookings Refusés</CardTitle>
<PackageX className="h-5 w-5 text-red-600" />
</CardHeader>
<CardContent>
{csvKpisLoading ? (
<div className="h-8 w-20 bg-gray-200 animate-pulse rounded" />
) : (
<>
<div className="text-3xl font-bold">{csvKpis?.totalRejected || 0}</div>
<p className="text-xs text-muted-foreground mt-2">
+{csvKpis?.rejectedThisMonth || 0} ce mois
</p>
</>
)}
</CardContent>
</Card>
{/* Bookings En Attente */}
<Card className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">En Attente</CardTitle>
<Clock className="h-5 w-5 text-yellow-600" />
</CardHeader>
<CardContent>
{csvKpisLoading ? (
<div className="h-8 w-20 bg-gray-200 animate-pulse rounded" />
) : (
<>
<div className="text-3xl font-bold">{csvKpis?.totalPending || 0}</div>
<p className="text-xs text-muted-foreground mt-2">
{csvKpis?.acceptanceRate.toFixed(1)}% taux d'acceptation
</p>
</>
)}
</CardContent>
</Card>
{/* Poids Total Accepté */}
<Card className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Poids Total Accepté</CardTitle>
<Weight className="h-5 w-5 text-blue-600" />
</CardHeader>
<CardContent>
{csvKpisLoading ? (
<div className="h-8 w-20 bg-gray-200 animate-pulse rounded" />
) : (
<>
<div className="text-3xl font-bold">
{(csvKpis?.totalWeightAcceptedKG || 0).toLocaleString()}
</div>
<p className="text-xs text-muted-foreground mt-2">
KG ({(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(2)} CBM)
</p>
</>
)}
</CardContent>
</Card>
</div>
{/* Stats Overview Card */}
<Card>
<CardHeader>
<CardTitle>Vue d'ensemble</CardTitle>
<CardDescription>Statistiques globales de vos bookings</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-6 md:grid-cols-3">
<div className="flex items-center space-x-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<TrendingUp className="h-6 w-6 text-green-600" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Taux d'acceptation</p>
<p className="text-2xl font-bold">
{csvKpisLoading ? '--' : `${csvKpis?.acceptanceRate.toFixed(1)}%`}
</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
<Package className="h-6 w-6 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Total bookings</p>
<p className="text-2xl font-bold">
{csvKpisLoading
? '--'
: (csvKpis?.totalAccepted || 0) +
(csvKpis?.totalRejected || 0) +
(csvKpis?.totalPending || 0)}
</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-purple-100">
<Weight className="h-6 w-6 text-purple-600" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Volume total accepté</p>
<p className="text-2xl font-bold">
{csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`}
<span className="text-sm font-normal text-muted-foreground ml-1">CBM</span>
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Top Carriers Section */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Meilleures Compagnies</CardTitle>
<CardDescription>Top 5 des transporteurs avec qui vous avez le plus booké</CardDescription>
</div>
<Link href="/dashboard/bookings">
<Button variant="ghost" size="sm" className="gap-2">
Voir tous
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</CardHeader>
<CardContent>
{carriersLoading ? (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-16 bg-gray-100 animate-pulse rounded"></div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-20 bg-gray-100 animate-pulse rounded" />
))}
</div>
) : recentBookings && recentBookings.bookings.length > 0 ? (
) : topCarriers && topCarriers.length > 0 ? (
<div className="space-y-4">
{recentBookings.bookings.map((booking: any) => (
<Link
key={booking.id}
href={`/dashboard/bookings/${booking.id}`}
className="flex items-center justify-between p-4 hover:bg-gray-50 rounded-lg transition-colors"
{topCarriers.map((carrier, index) => (
<div
key={carrier.carrierName}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent/50 transition-colors"
>
<div className="flex items-center space-x-4">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
📦
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-bold text-primary">
#{index + 1}
</div>
<div>
<div className="font-medium text-gray-900">{booking.bookingNumber}</div>
<div className="text-sm text-gray-500">
{new Date(booking.createdAt).toLocaleDateString()}
</div>
<h3 className="font-semibold">{carrier.carrierName}</h3>
<p className="text-sm text-muted-foreground">
{carrier.totalBookings} bookings {carrier.totalWeightKG.toLocaleString()} KG
</p>
</div>
</div>
<div className="flex items-center space-x-4">
<span
className={`px-3 py-1 rounded-full text-xs font-medium ${
booking.status === 'confirmed'
? 'bg-green-100 text-green-800'
: booking.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{booking.status}
</span>
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
<div className="text-right">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-green-100 text-green-800">
{carrier.acceptedBookings} acceptés
</Badge>
{carrier.rejectedBookings > 0 && (
<Badge variant="secondary" className="bg-red-100 text-red-800">
{carrier.rejectedBookings} refusés
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
Taux: {carrier.acceptanceRate.toFixed(0)}% Moy:{' '}
${carrier.avgPriceUSD.toFixed(0)}
</p>
</div>
</div>
</Link>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<div className="text-6xl mb-4">📦</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">No bookings yet</h3>
<p className="text-gray-500 mb-6">Create your first booking to get started</p>
<Link
href="/dashboard/bookings/new"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Create Booking
<Package className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Aucun booking pour l'instant</h3>
<p className="text-muted-foreground mb-6">
Créez votre premier booking pour voir vos statistiques
</p>
<Link href="/dashboard/bookings/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Créer un booking
</Button>
</Link>
</div>
)}
</div>
</CardContent>
</Card>
{/* Quick Actions */}
<div className="grid gap-4 md:grid-cols-3">
<Link href="/dashboard/search">
<Card className="hover:shadow-lg transition-all hover:-translate-y-1 cursor-pointer">
<CardContent className="flex items-center space-x-4 p-6">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-100">
<svg
className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<div>
<h3 className="font-semibold">Rechercher des tarifs</h3>
<p className="text-sm text-muted-foreground">Trouver les meilleurs prix</p>
</div>
</CardContent>
</Card>
</Link>
<Link href="/dashboard/bookings">
<Card className="hover:shadow-lg transition-all hover:-translate-y-1 cursor-pointer">
<CardContent className="flex items-center space-x-4 p-6">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-purple-100">
<Package className="h-6 w-6 text-purple-600" />
</div>
<div>
<h3 className="font-semibold">Mes Bookings</h3>
<p className="text-sm text-muted-foreground">Voir tous mes envois</p>
</div>
</CardContent>
</Card>
</Link>
<Link href="/dashboard/settings/organization">
<Card className="hover:shadow-lg transition-all hover:-translate-y-1 cursor-pointer">
<CardContent className="flex items-center space-x-4 p-6">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gray-100">
<svg
className="h-6 w-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div>
<h3 className="font-semibold">Paramètres</h3>
<p className="text-sm text-muted-foreground">Configuration du compte</p>
</div>
</CardContent>
</Card>
</Link>
</div>
</div>
);

View File

@ -83,6 +83,50 @@ export async function getAlerts(): Promise<DashboardAlert[]> {
return get<DashboardAlert[]>('/api/v1/dashboard/alerts');
}
/**
* CSV Booking KPIs Response
*/
export interface CsvBookingKPIs {
totalAccepted: number;
totalRejected: number;
totalPending: number;
totalWeightAcceptedKG: number;
totalVolumeAcceptedCBM: number;
acceptanceRate: number;
acceptedThisMonth: number;
rejectedThisMonth: number;
}
/**
* Top Carrier Data
*/
export interface TopCarrier {
carrierName: string;
totalBookings: number;
acceptedBookings: number;
rejectedBookings: number;
acceptanceRate: number;
totalWeightKG: number;
totalVolumeCBM: number;
avgPriceUSD: number;
}
/**
* Get CSV booking KPIs
* GET /api/v1/dashboard/csv-booking-kpis
*/
export async function getCsvBookingKPIs(): Promise<CsvBookingKPIs> {
return get<CsvBookingKPIs>('/api/v1/dashboard/csv-booking-kpis');
}
/**
* Get top carriers (top 5 by booking count)
* GET /api/v1/dashboard/top-carriers
*/
export async function getTopCarriers(): Promise<TopCarrier[]> {
return get<TopCarrier[]>('/api/v1/dashboard/top-carriers');
}
/**
* Export all dashboard APIs
*/
@ -91,4 +135,6 @@ export const dashboardApi = {
getBookingsChart,
getTopTradeLanes,
getAlerts,
getCsvBookingKPIs,
getTopCarriers,
};