From eab3d6f612c3e45ce4d5b4d35357610d4c727835 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 16 Dec 2025 00:26:03 +0100 Subject: [PATCH] feature dashboard --- .../src/application/auth/auth.service.ts | 9 +- .../controllers/admin/csv-rates.controller.ts | 19 +- .../csv-booking-actions.controller.ts | 184 ++-- .../controllers/csv-bookings.controller.ts | 9 +- .../controllers/invitations.controller.ts | 14 +- .../src/application/csv-bookings.module.ts | 11 +- .../dashboard/dashboard.controller.ts | 20 + .../application/dashboard/dashboard.module.ts | 3 +- .../src/application/dto/auth-login.dto.ts | 17 +- .../application/services/analytics.service.ts | 156 +++- .../services/carrier-auth.service.ts | 25 +- .../services/csv-booking.service.ts | 6 +- .../services/invitation.service.ts | 15 +- .../domain/ports/out/csv-rate-loader.port.ts | 6 +- .../services/csv-rate-search.service.ts | 30 +- .../rate-offer-generator.service.spec.ts | 866 +++++++++--------- .../services/rate-offer-generator.service.ts | 512 ++++++----- .../csv-loader/csv-rate-loader.adapter.ts | 24 +- .../entities/carrier-activity.orm-entity.ts | 2 +- .../entities/carrier-profile.orm-entity.ts | 4 +- .../entities/csv-booking.orm-entity.ts | 2 +- .../migrations/1730000000007-SeedTestUsers.ts | 3 +- .../carrier-activity.repository.ts | 18 +- .../carrier-profile.repository.ts | 9 +- .../src/scripts/delete-orphaned-csv-config.ts | 85 +- .../src/scripts/migrate-csv-to-minio.ts | 236 ++--- apps/backend/test/carrier-portal.e2e-spec.ts | 8 +- apps/frontend/app/dashboard/page.tsx | 654 +++++++------ apps/frontend/src/lib/api/dashboard.ts | 46 + 29 files changed, 1628 insertions(+), 1365 deletions(-) diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index 62f2763..9e20439 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -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; } diff --git a/apps/backend/src/application/controllers/admin/csv-rates.controller.ts b/apps/backend/src/application/controllers/admin/csv-rates.controller.ts index 700299d..fe1c5c6 100644 --- a/apps/backend/src/application/controllers/admin/csv-rates.controller.ts +++ b/apps/backend/src/application/controllers/admin/csv-rates.controller.ts @@ -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}`); diff --git a/apps/backend/src/application/controllers/csv-booking-actions.controller.ts b/apps/backend/src/application/controllers/csv-booking-actions.controller.ts index 10c0e91..f957d02 100644 --- a/apps/backend/src/application/controllers/csv-booking-actions.controller.ts +++ b/apps/backend/src/application/controllers/csv-booking-actions.controller.ts @@ -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, + }; + } +} diff --git a/apps/backend/src/application/controllers/csv-bookings.controller.ts b/apps/backend/src/application/controllers/csv-bookings.controller.ts index 231bd62..8436ca0 100644 --- a/apps/backend/src/application/controllers/csv-bookings.controller.ts +++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts @@ -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); diff --git a/apps/backend/src/application/controllers/invitations.controller.ts b/apps/backend/src/application/controllers/invitations.controller.ts index 642dc97..0a8677d 100644 --- a/apps/backend/src/application/controllers/invitations.controller.ts +++ b/apps/backend/src/application/controllers/invitations.controller.ts @@ -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'; diff --git a/apps/backend/src/application/csv-bookings.module.ts b/apps/backend/src/application/csv-bookings.module.ts index 5d3c583..b9b1ef2 100644 --- a/apps/backend/src/application/csv-bookings.module.ts +++ b/apps/backend/src/application/csv-bookings.module.ts @@ -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 {} diff --git a/apps/backend/src/application/dashboard/dashboard.controller.ts b/apps/backend/src/application/dashboard/dashboard.controller.ts index 23871f9..12d6d2f 100644 --- a/apps/backend/src/application/dashboard/dashboard.controller.ts +++ b/apps/backend/src/application/dashboard/dashboard.controller.ts @@ -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); + } } diff --git a/apps/backend/src/application/dashboard/dashboard.module.ts b/apps/backend/src/application/dashboard/dashboard.module.ts index b97c47a..75cfaf8 100644 --- a/apps/backend/src/application/dashboard/dashboard.module.ts +++ b/apps/backend/src/application/dashboard/dashboard.module.ts @@ -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], diff --git a/apps/backend/src/application/dto/auth-login.dto.ts b/apps/backend/src/application/dto/auth-login.dto.ts index 01ec645..20aec51 100644 --- a/apps/backend/src/application/dto/auth-login.dto.ts +++ b/apps/backend/src/application/dto/auth-login.dto.ts @@ -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, }) diff --git a/apps/backend/src/application/services/analytics.service.ts b/apps/backend/src/application/services/analytics.service.ts index 0d42dbf..2d75127 100644 --- a/apps/backend/src/application/services/analytics.service.ts +++ b/apps/backend/src/application/services/analytics.service.ts @@ -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 { + 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 { + 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); + } } diff --git a/apps/backend/src/application/services/carrier-auth.service.ts b/apps/backend/src/application/services/carrier-auth.service.ts index 938d4c5..ea078ed 100644 --- a/apps/backend/src/application/services/carrier-auth.service.ts +++ b/apps/backend/src/application/services/carrier-auth.service.ts @@ -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); diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index 04b9f72..4e4e21b 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -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 { + async getBookingById( + id: string, + userId: string, + carrierId?: string + ): Promise { const booking = await this.csvBookingRepository.findById(id); if (!booking) { diff --git a/apps/backend/src/application/services/invitation.service.ts b/apps/backend/src/application/services/invitation.service.ts index 56e49c0..d3ac67e 100644 --- a/apps/backend/src/application/services/invitation.service.ts +++ b/apps/backend/src/application/services/invitation.service.ts @@ -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; } diff --git a/apps/backend/src/domain/ports/out/csv-rate-loader.port.ts b/apps/backend/src/domain/ports/out/csv-rate-loader.port.ts index f6f59f4..54c3050 100644 --- a/apps/backend/src/domain/ports/out/csv-rate-loader.port.ts +++ b/apps/backend/src/domain/ports/out/csv-rate-loader.port.ts @@ -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; + loadRatesFromCsv( + filePath: string, + companyEmail: string, + companyNameOverride?: string + ): Promise; /** * Load rates for a specific company diff --git a/apps/backend/src/domain/services/csv-rate-search.service.ts b/apps/backend/src/domain/services/csv-rate-search.service.ts index b28ed16..4deb351 100644 --- a/apps/backend/src/domain/services/csv-rate-search.service.ts +++ b/apps/backend/src/domain/services/csv-rate-search.service.ts @@ -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 => result.status === 'fulfilled') + .filter( + (result): result is PromiseFulfilledResult => 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 => result.status === 'fulfilled') + .filter( + (result): result is PromiseFulfilledResult => result.status === 'fulfilled' + ) .map(result => result.value); return rateArrays.flat(); diff --git a/apps/backend/src/domain/services/rate-offer-generator.service.spec.ts b/apps/backend/src/domain/services/rate-offer-generator.service.spec.ts index a512d0f..bb0c499 100644 --- a/apps/backend/src/domain/services/rate-offer-generator.service.spec.ts +++ b/apps/backend/src/domain/services/rate-offer-generator.service.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/backend/src/domain/services/rate-offer-generator.service.ts b/apps/backend/src/domain/services/rate-offer-generator.service.ts index 07c4e89..4a74ac5 100644 --- a/apps/backend/src/domain/services/rate-offer-generator.service.ts +++ b/apps/backend/src/domain/services/rate-offer-generator.service.ts @@ -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.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.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)); + } +} diff --git a/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts b/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts index 64710a0..87d6943 100644 --- a/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts +++ b/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts @@ -90,8 +90,14 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort { } } - async loadRatesFromCsv(filePath: string, companyEmail: string, companyNameOverride?: string): Promise { - this.logger.log(`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`); + async loadRatesFromCsv( + filePath: string, + companyEmail: string, + companyNameOverride?: string + ): Promise { + 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); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/carrier-activity.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/carrier-activity.orm-entity.ts index 705caad..7aad4f9 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/carrier-activity.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/carrier-activity.orm-entity.ts @@ -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' }) diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/carrier-profile.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/carrier-profile.orm-entity.ts index e708219..5109939 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/carrier-profile.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/carrier-profile.orm-entity.ts @@ -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[]; } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts index 6ea827d..e2d5051 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts @@ -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' }) diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts index e387fae..93ff9dd 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts @@ -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 = [ diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/carrier-activity.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/carrier-activity.repository.ts index e6cb681..55e9b4d 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/carrier-activity.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/carrier-activity.repository.ts @@ -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 { - 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 { + async findByCarrierId( + carrierId: string, + limit: number = 10 + ): Promise { 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; } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/carrier-profile.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/carrier-profile.repository.ts index 7a3b3d2..7e6a5da 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/carrier-profile.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/carrier-profile.repository.ts @@ -76,7 +76,10 @@ export class CarrierProfileRepository { return saved; } - async update(id: string, data: Partial): Promise { + async update( + id: string, + data: Partial + ): Promise { 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; } diff --git a/apps/backend/src/scripts/delete-orphaned-csv-config.ts b/apps/backend/src/scripts/delete-orphaned-csv-config.ts index b995e24..2baedd8 100644 --- a/apps/backend/src/scripts/delete-orphaned-csv-config.ts +++ b/apps/backend/src/scripts/delete-orphaned-csv-config.ts @@ -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(); diff --git a/apps/backend/src/scripts/migrate-csv-to-minio.ts b/apps/backend/src/scripts/migrate-csv-to-minio.ts index 48feee4..29917cc 100644 --- a/apps/backend/src/scripts/migrate-csv-to-minio.ts +++ b/apps/backend/src/scripts/migrate-csv-to-minio.ts @@ -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('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('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(); diff --git a/apps/backend/test/carrier-portal.e2e-spec.ts b/apps/backend/test/carrier-portal.e2e-spec.ts index d567237..5824190 100644 --- a/apps/backend/test/carrier-portal.e2e-spec.ts +++ b/apps/backend/test/carrier-portal.e2e-spec.ts @@ -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); }); }); diff --git a/apps/frontend/app/dashboard/page.tsx b/apps/frontend/app/dashboard/page.tsx index 3ccfa0a..1969448 100644 --- a/apps/frontend/app/dashboard/page.tsx +++ b/apps/frontend/app/dashboard/page.tsx @@ -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 ( -
- {/* Welcome Section */} -
-

Welcome back!

-

Here's what's happening with your shipments today.

-
- - {/* KPI Cards */} -
- {kpisLoading ? ( - // Loading skeletons - Array.from({ length: 4 }).map((_, i) => ( -
-
-
-
- )) - ) : ( - <> -
-
-
-

Bookings This Month

-

- {kpis?.bookingsThisMonth || 0} -

-
-
📦
-
-
- - {formatChange(kpis?.bookingsThisMonthChange || 0)} - - vs last month -
-
- -
-
-
-

Total TEUs

-

{kpis?.totalTEUs || 0}

-
-
📊
-
-
- - {formatChange(kpis?.totalTEUsChange || 0)} - - vs last month -
-
- -
-
-
-

Estimated Revenue

-

- ${(kpis?.estimatedRevenue || 0).toLocaleString()} -

-
-
💰
-
-
- - {formatChange(kpis?.estimatedRevenueChange || 0)} - - vs last month -
-
- -
-
-
-

Pending Confirmations

-

- {kpis?.pendingConfirmations || 0} -

-
-
-
-
- - {formatChange(kpis?.pendingConfirmationsChange || 0)} - - vs last month -
-
- - )} -
- - {/* Alerts Section */} - {!alertsLoading && alerts && alerts.length > 0 && ( -
-

⚠️ Alerts & Notifications

-
- {alerts.slice(0, 5).map(alert => ( -
-
-
-

{alert.title}

-

{alert.message}

- {alert.bookingNumber && ( - - View Booking {alert.bookingNumber} - - )} -
- {alert.severity} -
-
- ))} -
-
- )} - - {/* Charts Section */} -
- {/* Bookings Trend Chart */} -
-

Bookings Trend (6 Months)

- {chartLoading ? ( -
- ) : ( - - - - - - - - - - - )} +
+ {/* Header Section */} +
+
+

Dashboard

+

+ Suivez vos bookings et vos performances +

- {/* Top Trade Lanes Chart */} -
-

Top 5 Trade Lanes

- {tradeLanesLoading ? ( -
- ) : ( - - - - - - - - - - - )} -
-
- - {/* Quick Actions */} -
- -
-
- 🔍 -
-
-

Search Rates

-

Find the best shipping rates

-
-
- - - -
-
- ➕ -
-
-

New Booking

-

Create a new shipment

-
-
- - - -
-
- 📋 -
-
-

My Bookings

-

View all your shipments

-
-
+ {/* CTA Button */} + +
- {/* Recent Bookings */} -
-
-

Recent Bookings

- - View All → - -
-
- {bookingsLoading ? ( + {/* KPI Cards Grid */} +
+ {/* Bookings Acceptés */} + + + Bookings Acceptés + + + + {csvKpisLoading ? ( +
+ ) : ( + <> +
{csvKpis?.totalAccepted || 0}
+

+ +{csvKpis?.acceptedThisMonth || 0} ce mois +

+ + )} + + + + {/* Bookings Refusés */} + + + Bookings Refusés + + + + {csvKpisLoading ? ( +
+ ) : ( + <> +
{csvKpis?.totalRejected || 0}
+

+ +{csvKpis?.rejectedThisMonth || 0} ce mois +

+ + )} + + + + {/* Bookings En Attente */} + + + En Attente + + + + {csvKpisLoading ? ( +
+ ) : ( + <> +
{csvKpis?.totalPending || 0}
+

+ {csvKpis?.acceptanceRate.toFixed(1)}% taux d'acceptation +

+ + )} + + + + {/* Poids Total Accepté */} + + + Poids Total Accepté + + + + {csvKpisLoading ? ( +
+ ) : ( + <> +
+ {(csvKpis?.totalWeightAcceptedKG || 0).toLocaleString()} +
+

+ KG ({(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(2)} CBM) +

+ + )} + + +
+ + {/* Stats Overview Card */} + + + Vue d'ensemble + Statistiques globales de vos bookings + + +
+
+
+ +
+
+

Taux d'acceptation

+

+ {csvKpisLoading ? '--' : `${csvKpis?.acceptanceRate.toFixed(1)}%`} +

+
+
+ +
+
+ +
+
+

Total bookings

+

+ {csvKpisLoading + ? '--' + : (csvKpis?.totalAccepted || 0) + + (csvKpis?.totalRejected || 0) + + (csvKpis?.totalPending || 0)} +

+
+
+ +
+
+ +
+
+

Volume total accepté

+

+ {csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`} + CBM +

+
+
+
+
+
+ + {/* Top Carriers Section */} + + +
+
+ Meilleures Compagnies + Top 5 des transporteurs avec qui vous avez le plus booké +
+ + + +
+
+ + {carriersLoading ? (
- {Array.from({ length: 3 }).map((_, i) => ( -
+ {Array.from({ length: 5 }).map((_, i) => ( +
))}
- ) : recentBookings && recentBookings.bookings.length > 0 ? ( + ) : topCarriers && topCarriers.length > 0 ? (
- {recentBookings.bookings.map((booking: any) => ( - ( +
-
- 📦 +
+ #{index + 1}
-
{booking.bookingNumber}
-
- {new Date(booking.createdAt).toLocaleDateString()} -
+

{carrier.carrierName}

+

+ {carrier.totalBookings} bookings • {carrier.totalWeightKG.toLocaleString()} KG +

+
- - {booking.status} - - - - +
+
+ + {carrier.acceptedBookings} acceptés + + {carrier.rejectedBookings > 0 && ( + + {carrier.rejectedBookings} refusés + + )} +
+

+ Taux: {carrier.acceptanceRate.toFixed(0)}% • Moy:{' '} + ${carrier.avgPriceUSD.toFixed(0)} +

+
- +
))}
) : (
-
📦
-

No bookings yet

-

Create your first booking to get started

- - Create Booking + +

Aucun booking pour l'instant

+

+ Créez votre premier booking pour voir vos statistiques +

+ +
)} -
+ + + + {/* Quick Actions */} +
+ + + +
+ + + +
+
+

Rechercher des tarifs

+

Trouver les meilleurs prix

+
+
+
+ + + + + +
+ +
+
+

Mes Bookings

+

Voir tous mes envois

+
+
+
+ + + + + +
+ + + + +
+
+

Paramètres

+

Configuration du compte

+
+
+
+
); diff --git a/apps/frontend/src/lib/api/dashboard.ts b/apps/frontend/src/lib/api/dashboard.ts index f17234c..de517eb 100644 --- a/apps/frontend/src/lib/api/dashboard.ts +++ b/apps/frontend/src/lib/api/dashboard.ts @@ -83,6 +83,50 @@ export async function getAlerts(): Promise { return get('/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 { + return get('/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 { + return get('/api/v1/dashboard/top-carriers'); +} + /** * Export all dashboard APIs */ @@ -91,4 +135,6 @@ export const dashboardApi = { getBookingsChart, getTopTradeLanes, getAlerts, + getCsvBookingKPIs, + getTopCarriers, };