feature dashboard
This commit is contained in:
parent
71541c79e7
commit
eab3d6f612
@ -11,7 +11,10 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
import { User, UserRole } from '@domain/entities/user.entity';
|
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 { Organization, OrganizationType } from '@domain/entities/organization.entity';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { DEFAULT_ORG_ID } from '@infrastructure/persistence/typeorm/seeds/test-organizations.seed';
|
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);
|
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;
|
return savedOrganization.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -181,7 +181,7 @@ export class CsvRatesAdminController {
|
|||||||
|
|
||||||
// Auto-convert CSV if needed (FOB FRET → Standard format)
|
// Auto-convert CSV if needed (FOB FRET → Standard format)
|
||||||
const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName);
|
const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName);
|
||||||
let filePathToValidate = conversionResult.convertedPath;
|
const filePathToValidate = conversionResult.convertedPath;
|
||||||
|
|
||||||
if (conversionResult.wasConverted) {
|
if (conversionResult.wasConverted) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@ -204,7 +204,11 @@ export class CsvRatesAdminController {
|
|||||||
|
|
||||||
// Load rates to verify parsing using the converted path
|
// Load rates to verify parsing using the converted path
|
||||||
// Pass company name from form to override CSV column value
|
// 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;
|
const ratesCount = rates.length;
|
||||||
|
|
||||||
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);
|
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);
|
||||||
@ -245,7 +249,9 @@ export class CsvRatesAdminController {
|
|||||||
minioObjectKey = objectKey;
|
minioObjectKey = objectKey;
|
||||||
this.logger.log(`✅ CSV file uploaded to MinIO: ${bucket}/${objectKey}`);
|
this.logger.log(`✅ CSV file uploaded to MinIO: ${bucket}/${objectKey}`);
|
||||||
} catch (error: any) {
|
} 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
|
// Don't fail the entire operation if MinIO upload fails
|
||||||
// The file is still available locally
|
// The file is still available locally
|
||||||
}
|
}
|
||||||
@ -433,7 +439,8 @@ export class CsvRatesAdminController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'List all CSV files (ADMIN only)',
|
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({
|
@ApiResponse({
|
||||||
status: HttpStatus.OK,
|
status: HttpStatus.OK,
|
||||||
@ -462,7 +469,7 @@ export class CsvRatesAdminController {
|
|||||||
const configs = await this.csvConfigRepository.findAll();
|
const configs = await this.csvConfigRepository.findAll();
|
||||||
|
|
||||||
// Map configs to file info format expected by frontend
|
// Map configs to file info format expected by frontend
|
||||||
const files = configs.map((config) => {
|
const files = configs.map(config => {
|
||||||
const filePath = path.join(
|
const filePath = path.join(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
'apps/backend/src/infrastructure/storage/csv-storage/rates',
|
'apps/backend/src/infrastructure/storage/csv-storage/rates',
|
||||||
@ -521,7 +528,7 @@ export class CsvRatesAdminController {
|
|||||||
|
|
||||||
// Find config by file path
|
// Find config by file path
|
||||||
const configs = await this.csvConfigRepository.findAll();
|
const configs = await this.csvConfigRepository.findAll();
|
||||||
const config = configs.find((c) => c.csvFilePath === filename);
|
const config = configs.find(c => c.csvFilePath === filename);
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new BadRequestException(`No configuration found for file: ${filename}`);
|
throw new BadRequestException(`No configuration found for file: ${filename}`);
|
||||||
|
|||||||
@ -1,93 +1,91 @@
|
|||||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
import { CsvBookingService } from '../services/csv-booking.service';
|
import { CsvBookingService } from '../services/csv-booking.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Booking Actions Controller (Public Routes)
|
* CSV Booking Actions Controller (Public Routes)
|
||||||
*
|
*
|
||||||
* Handles public accept/reject actions from carrier emails
|
* Handles public accept/reject actions from carrier emails
|
||||||
* Separated from main controller to avoid routing conflicts
|
* Separated from main controller to avoid routing conflicts
|
||||||
*/
|
*/
|
||||||
@ApiTags('CSV Booking Actions')
|
@ApiTags('CSV Booking Actions')
|
||||||
@Controller('csv-booking-actions')
|
@Controller('csv-booking-actions')
|
||||||
export class CsvBookingActionsController {
|
export class CsvBookingActionsController {
|
||||||
constructor(
|
constructor(private readonly csvBookingService: CsvBookingService) {}
|
||||||
private readonly csvBookingService: CsvBookingService
|
|
||||||
) {}
|
/**
|
||||||
|
* Accept a booking request (PUBLIC - token-based)
|
||||||
/**
|
*
|
||||||
* Accept a booking request (PUBLIC - token-based)
|
* GET /api/v1/csv-booking-actions/accept/:token
|
||||||
*
|
*/
|
||||||
* GET /api/v1/csv-booking-actions/accept/:token
|
@Public()
|
||||||
*/
|
@Get('accept/:token')
|
||||||
@Public()
|
@ApiOperation({
|
||||||
@Get('accept/:token')
|
summary: 'Accept booking request (public)',
|
||||||
@ApiOperation({
|
description:
|
||||||
summary: 'Accept booking request (public)',
|
'Public endpoint for carriers to accept a booking via email link. Updates booking status and notifies the user.',
|
||||||
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({
|
||||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
status: 200,
|
||||||
@ApiResponse({
|
description: 'Booking accepted successfully.',
|
||||||
status: 200,
|
})
|
||||||
description: 'Booking accepted successfully.',
|
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
||||||
})
|
@ApiResponse({
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
status: 400,
|
||||||
@ApiResponse({
|
description: 'Booking cannot be accepted (invalid status or expired)',
|
||||||
status: 400,
|
})
|
||||||
description: 'Booking cannot be accepted (invalid status or expired)',
|
async acceptBooking(@Param('token') token: string) {
|
||||||
})
|
// Accept the booking
|
||||||
async acceptBooking(@Param('token') token: string) {
|
const booking = await this.csvBookingService.acceptBooking(token);
|
||||||
// Accept the booking
|
|
||||||
const booking = await this.csvBookingService.acceptBooking(token);
|
// Return simple success response
|
||||||
|
return {
|
||||||
// Return simple success response
|
success: true,
|
||||||
return {
|
bookingId: booking.id,
|
||||||
success: true,
|
action: 'accepted',
|
||||||
bookingId: booking.id,
|
};
|
||||||
action: 'accepted',
|
}
|
||||||
};
|
|
||||||
}
|
/**
|
||||||
|
* Reject a booking request (PUBLIC - token-based)
|
||||||
/**
|
*
|
||||||
* Reject a booking request (PUBLIC - token-based)
|
* GET /api/v1/csv-booking-actions/reject/:token
|
||||||
*
|
*/
|
||||||
* GET /api/v1/csv-booking-actions/reject/:token
|
@Public()
|
||||||
*/
|
@Get('reject/:token')
|
||||||
@Public()
|
@ApiOperation({
|
||||||
@Get('reject/:token')
|
summary: 'Reject booking request (public)',
|
||||||
@ApiOperation({
|
description:
|
||||||
summary: 'Reject booking request (public)',
|
'Public endpoint for carriers to reject a booking via email link. Updates booking status and notifies the user.',
|
||||||
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({
|
||||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
name: 'reason',
|
||||||
@ApiQuery({
|
required: false,
|
||||||
name: 'reason',
|
description: 'Rejection reason',
|
||||||
required: false,
|
example: 'No capacity available',
|
||||||
description: 'Rejection reason',
|
})
|
||||||
example: 'No capacity available',
|
@ApiResponse({
|
||||||
})
|
status: 200,
|
||||||
@ApiResponse({
|
description: 'Booking rejected successfully.',
|
||||||
status: 200,
|
})
|
||||||
description: 'Booking rejected successfully.',
|
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
||||||
})
|
@ApiResponse({
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
status: 400,
|
||||||
@ApiResponse({
|
description: 'Booking cannot be rejected (invalid status or expired)',
|
||||||
status: 400,
|
})
|
||||||
description: 'Booking cannot be rejected (invalid status or expired)',
|
async rejectBooking(@Param('token') token: string, @Query('reason') reason: string) {
|
||||||
})
|
// Reject the booking
|
||||||
async rejectBooking(@Param('token') token: string, @Query('reason') reason: string) {
|
const booking = await this.csvBookingService.rejectBooking(token, reason);
|
||||||
// Reject the booking
|
|
||||||
const booking = await this.csvBookingService.rejectBooking(token, reason);
|
// Return simple success response
|
||||||
|
return {
|
||||||
// Return simple success response
|
success: true,
|
||||||
return {
|
bookingId: booking.id,
|
||||||
success: true,
|
action: 'rejected',
|
||||||
bookingId: booking.id,
|
reason: reason || null,
|
||||||
action: 'rejected',
|
};
|
||||||
reason: reason || null,
|
}
|
||||||
};
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -47,9 +47,7 @@ import {
|
|||||||
@ApiTags('CSV Bookings')
|
@ApiTags('CSV Bookings')
|
||||||
@Controller('csv-bookings')
|
@Controller('csv-bookings')
|
||||||
export class CsvBookingsController {
|
export class CsvBookingsController {
|
||||||
constructor(
|
constructor(private readonly csvBookingService: CsvBookingService) {}
|
||||||
private readonly csvBookingService: CsvBookingService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new CSV booking request
|
* Create a new CSV booking request
|
||||||
@ -219,10 +217,7 @@ export class CsvBookingsController {
|
|||||||
status: 400,
|
status: 400,
|
||||||
description: 'Booking cannot be rejected (invalid status or expired)',
|
description: 'Booking cannot be rejected (invalid status or expired)',
|
||||||
})
|
})
|
||||||
async rejectBooking(
|
async rejectBooking(@Param('token') token: string, @Query('reason') reason: string) {
|
||||||
@Param('token') token: string,
|
|
||||||
@Query('reason') reason: string
|
|
||||||
) {
|
|
||||||
// Reject the booking
|
// Reject the booking
|
||||||
const booking = await this.csvBookingService.rejectBooking(token, reason);
|
const booking = await this.csvBookingService.rejectBooking(token, reason);
|
||||||
|
|
||||||
|
|||||||
@ -10,15 +10,13 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
|
||||||
ApiTags,
|
|
||||||
ApiOperation,
|
|
||||||
ApiResponse,
|
|
||||||
ApiBearerAuth,
|
|
||||||
ApiParam,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { InvitationService } from '../services/invitation.service';
|
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 { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../guards/roles.guard';
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
import { Roles } from '../decorators/roles.decorator';
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
|
|||||||
@ -16,18 +16,13 @@ import { StorageModule } from '../infrastructure/storage/storage.module';
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([CsvBookingOrmEntity]),
|
||||||
CsvBookingOrmEntity,
|
|
||||||
]),
|
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
],
|
],
|
||||||
controllers: [CsvBookingsController, CsvBookingActionsController],
|
controllers: [CsvBookingsController, CsvBookingActionsController],
|
||||||
providers: [
|
providers: [CsvBookingService, TypeOrmCsvBookingRepository],
|
||||||
CsvBookingService,
|
exports: [CsvBookingService, TypeOrmCsvBookingRepository],
|
||||||
TypeOrmCsvBookingRepository,
|
|
||||||
],
|
|
||||||
exports: [CsvBookingService],
|
|
||||||
})
|
})
|
||||||
export class CsvBookingsModule {}
|
export class CsvBookingsModule {}
|
||||||
|
|||||||
@ -52,4 +52,24 @@ export class DashboardController {
|
|||||||
const organizationId = req.user.organizationId;
|
const organizationId = req.user.organizationId;
|
||||||
return this.analyticsService.getAlerts(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,10 @@ import { DashboardController } from './dashboard.controller';
|
|||||||
import { AnalyticsService } from '../services/analytics.service';
|
import { AnalyticsService } from '../services/analytics.service';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { RatesModule } from '../rates/rates.module';
|
import { RatesModule } from '../rates/rates.module';
|
||||||
|
import { CsvBookingsModule } from '../csv-bookings.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BookingsModule, RatesModule],
|
imports: [BookingsModule, RatesModule, CsvBookingsModule],
|
||||||
controllers: [DashboardController],
|
controllers: [DashboardController],
|
||||||
providers: [AnalyticsService],
|
providers: [AnalyticsService],
|
||||||
exports: [AnalyticsService],
|
exports: [AnalyticsService],
|
||||||
|
|||||||
@ -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 { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { OrganizationType } from '@domain/entities/organization.entity';
|
import { OrganizationType } from '@domain/entities/organization.entity';
|
||||||
@ -145,7 +154,8 @@ export class RegisterDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
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,
|
required: false,
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@ -153,7 +163,8 @@ export class RegisterDto {
|
|||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@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,
|
type: RegisterOrganizationDto,
|
||||||
required: false,
|
required: false,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
|
|||||||
import { BookingRepository } 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 { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository';
|
||||||
import { RateQuoteRepository } 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 {
|
export interface DashboardKPIs {
|
||||||
bookingsThisMonth: number;
|
bookingsThisMonth: number;
|
||||||
@ -47,13 +49,36 @@ export interface DashboardAlert {
|
|||||||
isRead: boolean;
|
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()
|
@Injectable()
|
||||||
export class AnalyticsService {
|
export class AnalyticsService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(BOOKING_REPOSITORY)
|
@Inject(BOOKING_REPOSITORY)
|
||||||
private readonly bookingRepository: BookingRepository,
|
private readonly bookingRepository: BookingRepository,
|
||||||
@Inject(RATE_QUOTE_REPOSITORY)
|
@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;
|
return alerts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CSV Booking KPIs
|
||||||
|
*/
|
||||||
|
async getCsvBookingKPIs(organizationId: string): Promise<CsvBookingKPIs> {
|
||||||
|
const allCsvBookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
const acceptedBookings = allCsvBookings.filter(
|
||||||
|
(b: CsvBooking) => b.status === CsvBookingStatus.ACCEPTED
|
||||||
|
);
|
||||||
|
const rejectedBookings = allCsvBookings.filter(
|
||||||
|
(b: CsvBooking) => b.status === CsvBookingStatus.REJECTED
|
||||||
|
);
|
||||||
|
const pendingBookings = allCsvBookings.filter(
|
||||||
|
(b: CsvBooking) => b.status === CsvBookingStatus.PENDING
|
||||||
|
);
|
||||||
|
|
||||||
|
// This month stats
|
||||||
|
const acceptedThisMonth = acceptedBookings.filter(
|
||||||
|
(b: CsvBooking) => b.requestedAt >= thisMonthStart
|
||||||
|
).length;
|
||||||
|
const rejectedThisMonth = rejectedBookings.filter(
|
||||||
|
(b: CsvBooking) => b.requestedAt >= thisMonthStart
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Calculate total weight and volume for accepted bookings
|
||||||
|
const totalWeightAcceptedKG = acceptedBookings.reduce(
|
||||||
|
(sum: number, b: CsvBooking) => sum + b.weightKG,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalVolumeAcceptedCBM = acceptedBookings.reduce(
|
||||||
|
(sum: number, b: CsvBooking) => sum + b.volumeCBM,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate acceptance rate
|
||||||
|
const totalProcessed = acceptedBookings.length + rejectedBookings.length;
|
||||||
|
const acceptanceRate =
|
||||||
|
totalProcessed > 0 ? (acceptedBookings.length / totalProcessed) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAccepted: acceptedBookings.length,
|
||||||
|
totalRejected: rejectedBookings.length,
|
||||||
|
totalPending: pendingBookings.length,
|
||||||
|
totalWeightAcceptedKG,
|
||||||
|
totalVolumeAcceptedCBM,
|
||||||
|
acceptanceRate,
|
||||||
|
acceptedThisMonth,
|
||||||
|
rejectedThisMonth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Top Carriers by booking count
|
||||||
|
*/
|
||||||
|
async getTopCarriers(organizationId: string, limit: number = 5): Promise<TopCarrier[]> {
|
||||||
|
const allCsvBookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
|
||||||
|
|
||||||
|
// Group by carrier
|
||||||
|
const carrierMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
totalBookings: number;
|
||||||
|
acceptedBookings: number;
|
||||||
|
rejectedBookings: number;
|
||||||
|
totalWeightKG: number;
|
||||||
|
totalVolumeCBM: number;
|
||||||
|
totalPriceUSD: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const booking of allCsvBookings) {
|
||||||
|
const carrierName = booking.carrierName;
|
||||||
|
|
||||||
|
if (!carrierMap.has(carrierName)) {
|
||||||
|
carrierMap.set(carrierName, {
|
||||||
|
totalBookings: 0,
|
||||||
|
acceptedBookings: 0,
|
||||||
|
rejectedBookings: 0,
|
||||||
|
totalWeightKG: 0,
|
||||||
|
totalVolumeCBM: 0,
|
||||||
|
totalPriceUSD: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const carrier = carrierMap.get(carrierName)!;
|
||||||
|
carrier.totalBookings++;
|
||||||
|
|
||||||
|
if (booking.status === CsvBookingStatus.ACCEPTED) {
|
||||||
|
carrier.acceptedBookings++;
|
||||||
|
carrier.totalWeightKG += booking.weightKG;
|
||||||
|
carrier.totalVolumeCBM += booking.volumeCBM;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.status === CsvBookingStatus.REJECTED) {
|
||||||
|
carrier.rejectedBookings++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add price (prefer USD, fallback to EUR converted)
|
||||||
|
if (booking.priceUSD) {
|
||||||
|
carrier.totalPriceUSD += booking.priceUSD;
|
||||||
|
} else if (booking.priceEUR) {
|
||||||
|
// Simple EUR to USD conversion (1.1 rate) - in production, use real exchange rate
|
||||||
|
carrier.totalPriceUSD += booking.priceEUR * 1.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to array
|
||||||
|
const topCarriers: TopCarrier[] = Array.from(carrierMap.entries()).map(
|
||||||
|
([carrierName, data]) => ({
|
||||||
|
carrierName,
|
||||||
|
totalBookings: data.totalBookings,
|
||||||
|
acceptedBookings: data.acceptedBookings,
|
||||||
|
rejectedBookings: data.rejectedBookings,
|
||||||
|
acceptanceRate:
|
||||||
|
data.totalBookings > 0 ? (data.acceptedBookings / data.totalBookings) * 100 : 0,
|
||||||
|
totalWeightKG: data.totalWeightKG,
|
||||||
|
totalVolumeCBM: data.totalVolumeCBM,
|
||||||
|
avgPriceUSD: data.totalBookings > 0 ? data.totalPriceUSD / data.totalBookings : 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort by total bookings (most bookings first)
|
||||||
|
return topCarriers.sort((a, b) => b.totalBookings - a.totalBookings).slice(0, limit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,13 @@
|
|||||||
* Handles carrier authentication and automatic account creation
|
* 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 { JwtService } from '@nestjs/jwt';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
@ -111,7 +117,11 @@ export class CarrierAuthService {
|
|||||||
|
|
||||||
// Send welcome email with credentials and WAIT for confirmation
|
// Send welcome email with credentials and WAIT for confirmation
|
||||||
try {
|
try {
|
||||||
await this.emailAdapter.sendCarrierAccountCreated(carrierEmail, carrierName, temporaryPassword);
|
await this.emailAdapter.sendCarrierAccountCreated(
|
||||||
|
carrierEmail,
|
||||||
|
carrierName,
|
||||||
|
temporaryPassword
|
||||||
|
);
|
||||||
this.logger.log(`Account creation email sent to ${carrierEmail}`);
|
this.logger.log(`Account creation email sent to ${carrierEmail}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to send account creation email: ${error?.message}`, error?.stack);
|
this.logger.error(`Failed to send account creation email: ${error?.message}`, error?.stack);
|
||||||
@ -148,7 +158,10 @@ export class CarrierAuthService {
|
|||||||
/**
|
/**
|
||||||
* Standard login for carriers
|
* Standard login for carriers
|
||||||
*/
|
*/
|
||||||
async login(email: string, password: string): Promise<{
|
async login(
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
): Promise<{
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
carrier: {
|
carrier: {
|
||||||
@ -288,7 +301,11 @@ export class CarrierAuthService {
|
|||||||
|
|
||||||
// Send password reset email and WAIT for confirmation
|
// Send password reset email and WAIT for confirmation
|
||||||
try {
|
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}`);
|
this.logger.log(`Password reset email sent to ${email}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to send password reset email: ${error?.message}`, error?.stack);
|
this.logger.error(`Failed to send password reset email: ${error?.message}`, error?.stack);
|
||||||
|
|||||||
@ -161,7 +161,11 @@ export class CsvBookingService {
|
|||||||
* Get booking by ID
|
* Get booking by ID
|
||||||
* Accessible by: booking owner OR assigned carrier
|
* Accessible by: booking owner OR assigned carrier
|
||||||
*/
|
*/
|
||||||
async getBookingById(id: string, userId: string, carrierId?: string): Promise<CsvBookingResponseDto> {
|
async getBookingById(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
carrierId?: string
|
||||||
|
): Promise<CsvBookingResponseDto> {
|
||||||
const booking = await this.csvBookingRepository.findById(id);
|
const booking = await this.csvBookingRepository.findById(id);
|
||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
|
|||||||
@ -7,9 +7,15 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
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 { 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 { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
||||||
import { InvitationToken } from '@domain/entities/invitation-token.entity';
|
import { InvitationToken } from '@domain/entities/invitation-token.entity';
|
||||||
import { UserRole } from '@domain/entities/user.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}`);
|
this.logger.log(`[INVITATION] ✅ Email sent successfully to ${invitation.email}`);
|
||||||
} catch (error) {
|
} 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)}`);
|
this.logger.error(`[INVITATION] Error details: ${JSON.stringify(error, null, 2)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,11 @@ export interface CsvRateLoaderPort {
|
|||||||
* @returns Array of CSV rates
|
* @returns Array of CSV rates
|
||||||
* @throws Error if file cannot be read or parsed
|
* @throws Error if file cannot be read or parsed
|
||||||
*/
|
*/
|
||||||
loadRatesFromCsv(filePath: string, companyEmail: string, companyNameOverride?: string): Promise<CsvRate[]>;
|
loadRatesFromCsv(
|
||||||
|
filePath: string,
|
||||||
|
companyEmail: string,
|
||||||
|
companyNameOverride?: string
|
||||||
|
): Promise<CsvRate[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load rates for a specific company
|
* Load rates for a specific company
|
||||||
|
|||||||
@ -177,9 +177,13 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Apply service level price adjustment to the total price
|
// Apply service level price adjustment to the total price
|
||||||
const adjustedTotalPrice = priceBreakdown.totalPrice *
|
const adjustedTotalPrice =
|
||||||
(offer.serviceLevel === ServiceLevel.RAPID ? 1.20 :
|
priceBreakdown.totalPrice *
|
||||||
offer.serviceLevel === ServiceLevel.ECONOMIC ? 0.85 : 1.00);
|
(offer.serviceLevel === ServiceLevel.RAPID
|
||||||
|
? 1.2
|
||||||
|
: offer.serviceLevel === ServiceLevel.ECONOMIC
|
||||||
|
? 0.85
|
||||||
|
: 1.0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rate: offer.rate,
|
rate: offer.rate,
|
||||||
@ -207,8 +211,8 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
// Apply service level filter if specified
|
// Apply service level filter if specified
|
||||||
let filteredResults = results;
|
let filteredResults = results;
|
||||||
if (input.filters?.serviceLevels && input.filters.serviceLevels.length > 0) {
|
if (input.filters?.serviceLevels && input.filters.serviceLevels.length > 0) {
|
||||||
filteredResults = results.filter(r =>
|
filteredResults = results.filter(
|
||||||
r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel)
|
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
|
// Use allSettled to handle missing files gracefully
|
||||||
const results = await Promise.allSettled(ratePromises);
|
const results = await Promise.allSettled(ratePromises);
|
||||||
const rateArrays = results
|
const rateArrays = results
|
||||||
.filter((result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled')
|
.filter(
|
||||||
|
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
|
||||||
|
)
|
||||||
.map(result => result.value);
|
.map(result => result.value);
|
||||||
|
|
||||||
// Log any failed file loads
|
// Log any failed file loads
|
||||||
const failures = results.filter(result => result.status === 'rejected');
|
const failures = results.filter(result => result.status === 'rejected');
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
console.warn(`Failed to load ${failures.length} CSV files:`,
|
console.warn(
|
||||||
failures.map((f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`));
|
`Failed to load ${failures.length} CSV files:`,
|
||||||
|
failures.map(
|
||||||
|
(f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return rateArrays.flat();
|
return rateArrays.flat();
|
||||||
@ -274,7 +284,9 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
// Use allSettled here too for consistency
|
// Use allSettled here too for consistency
|
||||||
const results = await Promise.allSettled(ratePromises);
|
const results = await Promise.allSettled(ratePromises);
|
||||||
const rateArrays = results
|
const rateArrays = results
|
||||||
.filter((result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled')
|
.filter(
|
||||||
|
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
|
||||||
|
)
|
||||||
.map(result => result.value);
|
.map(result => result.value);
|
||||||
|
|
||||||
return rateArrays.flat();
|
return rateArrays.flat();
|
||||||
|
|||||||
@ -1,433 +1,433 @@
|
|||||||
import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.service';
|
import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.service';
|
||||||
import { CsvRate } from '../entities/csv-rate.entity';
|
import { CsvRate } from '../entities/csv-rate.entity';
|
||||||
import { PortCode } from '../value-objects/port-code.vo';
|
import { PortCode } from '../value-objects/port-code.vo';
|
||||||
import { ContainerType } from '../value-objects/container-type.vo';
|
import { ContainerType } from '../value-objects/container-type.vo';
|
||||||
import { Money } from '../value-objects/money.vo';
|
import { Money } from '../value-objects/money.vo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Suite for Rate Offer Generator Service
|
* Test Suite for Rate Offer Generator Service
|
||||||
*
|
*
|
||||||
* Vérifie que:
|
* Vérifie que:
|
||||||
* - RAPID est le plus cher ET le plus rapide
|
* - RAPID est le plus cher ET le plus rapide
|
||||||
* - ECONOMIC est le moins cher ET le plus lent
|
* - ECONOMIC est le moins cher ET le plus lent
|
||||||
* - STANDARD est au milieu en prix et transit time
|
* - STANDARD est au milieu en prix et transit time
|
||||||
*/
|
*/
|
||||||
describe('RateOfferGeneratorService', () => {
|
describe('RateOfferGeneratorService', () => {
|
||||||
let service: RateOfferGeneratorService;
|
let service: RateOfferGeneratorService;
|
||||||
let mockRate: CsvRate;
|
let mockRate: CsvRate;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
service = new RateOfferGeneratorService();
|
service = new RateOfferGeneratorService();
|
||||||
|
|
||||||
// Créer un tarif de base pour les tests
|
// Créer un tarif de base pour les tests
|
||||||
// Prix: 1000 USD / 900 EUR, Transit: 20 jours
|
// Prix: 1000 USD / 900 EUR, Transit: 20 jours
|
||||||
mockRate = {
|
mockRate = {
|
||||||
companyName: 'Test Carrier',
|
companyName: 'Test Carrier',
|
||||||
companyEmail: 'test@carrier.com',
|
companyEmail: 'test@carrier.com',
|
||||||
origin: PortCode.create('FRPAR'),
|
origin: PortCode.create('FRPAR'),
|
||||||
destination: PortCode.create('USNYC'),
|
destination: PortCode.create('USNYC'),
|
||||||
containerType: ContainerType.create('LCL'),
|
containerType: ContainerType.create('LCL'),
|
||||||
volumeRange: { minCBM: 1, maxCBM: 10 },
|
volumeRange: { minCBM: 1, maxCBM: 10 },
|
||||||
weightRange: { minKG: 100, maxKG: 5000 },
|
weightRange: { minKG: 100, maxKG: 5000 },
|
||||||
palletCount: 0,
|
palletCount: 0,
|
||||||
pricing: {
|
pricing: {
|
||||||
pricePerCBM: 100,
|
pricePerCBM: 100,
|
||||||
pricePerKG: 0.5,
|
pricePerKG: 0.5,
|
||||||
basePriceUSD: Money.create(1000, 'USD'),
|
basePriceUSD: Money.create(1000, 'USD'),
|
||||||
basePriceEUR: Money.create(900, 'EUR'),
|
basePriceEUR: Money.create(900, 'EUR'),
|
||||||
},
|
},
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
hasSurcharges: false,
|
hasSurcharges: false,
|
||||||
surchargeBAF: null,
|
surchargeBAF: null,
|
||||||
surchargeCAF: null,
|
surchargeCAF: null,
|
||||||
surchargeDetails: null,
|
surchargeDetails: null,
|
||||||
transitDays: 20,
|
transitDays: 20,
|
||||||
validity: {
|
validity: {
|
||||||
getStartDate: () => new Date('2024-01-01'),
|
getStartDate: () => new Date('2024-01-01'),
|
||||||
getEndDate: () => new Date('2024-12-31'),
|
getEndDate: () => new Date('2024-12-31'),
|
||||||
},
|
},
|
||||||
isValidForDate: () => true,
|
isValidForDate: () => true,
|
||||||
matchesRoute: () => true,
|
matchesRoute: () => true,
|
||||||
matchesVolume: () => true,
|
matchesVolume: () => true,
|
||||||
matchesPalletCount: () => true,
|
matchesPalletCount: () => true,
|
||||||
getPriceInCurrency: () => Money.create(1000, 'USD'),
|
getPriceInCurrency: () => Money.create(1000, 'USD'),
|
||||||
isAllInPrice: () => true,
|
isAllInPrice: () => true,
|
||||||
getSurchargeDetails: () => null,
|
getSurchargeDetails: () => null,
|
||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateOffers', () => {
|
describe('generateOffers', () => {
|
||||||
it('devrait générer exactement 3 offres (RAPID, STANDARD, ECONOMIC)', () => {
|
it('devrait générer exactement 3 offres (RAPID, STANDARD, ECONOMIC)', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
|
|
||||||
expect(offers).toHaveLength(3);
|
expect(offers).toHaveLength(3);
|
||||||
expect(offers.map(o => o.serviceLevel)).toEqual(
|
expect(offers.map(o => o.serviceLevel)).toEqual(
|
||||||
expect.arrayContaining([ServiceLevel.RAPID, ServiceLevel.STANDARD, ServiceLevel.ECONOMIC])
|
expect.arrayContaining([ServiceLevel.RAPID, ServiceLevel.STANDARD, ServiceLevel.ECONOMIC])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ECONOMIC doit être le moins cher', () => {
|
it('ECONOMIC doit être le moins cher', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
|
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
||||||
|
|
||||||
// ECONOMIC doit avoir le prix le plus bas
|
// ECONOMIC doit avoir le prix le plus bas
|
||||||
expect(economic!.adjustedPriceUSD).toBeLessThan(standard!.adjustedPriceUSD);
|
expect(economic!.adjustedPriceUSD).toBeLessThan(standard!.adjustedPriceUSD);
|
||||||
expect(economic!.adjustedPriceUSD).toBeLessThan(rapid!.adjustedPriceUSD);
|
expect(economic!.adjustedPriceUSD).toBeLessThan(rapid!.adjustedPriceUSD);
|
||||||
|
|
||||||
// Vérifier le prix attendu: 1000 * 0.85 = 850 USD
|
// Vérifier le prix attendu: 1000 * 0.85 = 850 USD
|
||||||
expect(economic!.adjustedPriceUSD).toBe(850);
|
expect(economic!.adjustedPriceUSD).toBe(850);
|
||||||
expect(economic!.priceAdjustmentPercent).toBe(-15);
|
expect(economic!.priceAdjustmentPercent).toBe(-15);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('RAPID doit être le plus cher', () => {
|
it('RAPID doit être le plus cher', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
|
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
||||||
|
|
||||||
// RAPID doit avoir le prix le plus élevé
|
// RAPID doit avoir le prix le plus élevé
|
||||||
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(standard!.adjustedPriceUSD);
|
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(standard!.adjustedPriceUSD);
|
||||||
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(economic!.adjustedPriceUSD);
|
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(economic!.adjustedPriceUSD);
|
||||||
|
|
||||||
// Vérifier le prix attendu: 1000 * 1.20 = 1200 USD
|
// Vérifier le prix attendu: 1000 * 1.20 = 1200 USD
|
||||||
expect(rapid!.adjustedPriceUSD).toBe(1200);
|
expect(rapid!.adjustedPriceUSD).toBe(1200);
|
||||||
expect(rapid!.priceAdjustmentPercent).toBe(20);
|
expect(rapid!.priceAdjustmentPercent).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('STANDARD doit avoir le prix de base (pas d\'ajustement)', () => {
|
it("STANDARD doit avoir le prix de base (pas d'ajustement)", () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
|
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||||
|
|
||||||
// STANDARD doit avoir le prix de base (pas de changement)
|
// STANDARD doit avoir le prix de base (pas de changement)
|
||||||
expect(standard!.adjustedPriceUSD).toBe(1000);
|
expect(standard!.adjustedPriceUSD).toBe(1000);
|
||||||
expect(standard!.adjustedPriceEUR).toBe(900);
|
expect(standard!.adjustedPriceEUR).toBe(900);
|
||||||
expect(standard!.priceAdjustmentPercent).toBe(0);
|
expect(standard!.priceAdjustmentPercent).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('RAPID doit être le plus rapide (moins de jours de transit)', () => {
|
it('RAPID doit être le plus rapide (moins de jours de transit)', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
|
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
||||||
|
|
||||||
// RAPID doit avoir le transit time le plus court
|
// RAPID doit avoir le transit time le plus court
|
||||||
expect(rapid!.adjustedTransitDays).toBeLessThan(standard!.adjustedTransitDays);
|
expect(rapid!.adjustedTransitDays).toBeLessThan(standard!.adjustedTransitDays);
|
||||||
expect(rapid!.adjustedTransitDays).toBeLessThan(economic!.adjustedTransitDays);
|
expect(rapid!.adjustedTransitDays).toBeLessThan(economic!.adjustedTransitDays);
|
||||||
|
|
||||||
// Vérifier le transit attendu: 20 * 0.70 = 14 jours
|
// Vérifier le transit attendu: 20 * 0.70 = 14 jours
|
||||||
expect(rapid!.adjustedTransitDays).toBe(14);
|
expect(rapid!.adjustedTransitDays).toBe(14);
|
||||||
expect(rapid!.transitAdjustmentPercent).toBe(-30);
|
expect(rapid!.transitAdjustmentPercent).toBe(-30);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ECONOMIC doit être le plus lent (plus de jours de transit)', () => {
|
it('ECONOMIC doit être le plus lent (plus de jours de transit)', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
|
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
||||||
|
|
||||||
// ECONOMIC doit avoir le transit time le plus long
|
// ECONOMIC doit avoir le transit time le plus long
|
||||||
expect(economic!.adjustedTransitDays).toBeGreaterThan(standard!.adjustedTransitDays);
|
expect(economic!.adjustedTransitDays).toBeGreaterThan(standard!.adjustedTransitDays);
|
||||||
expect(economic!.adjustedTransitDays).toBeGreaterThan(rapid!.adjustedTransitDays);
|
expect(economic!.adjustedTransitDays).toBeGreaterThan(rapid!.adjustedTransitDays);
|
||||||
|
|
||||||
// Vérifier le transit attendu: 20 * 1.50 = 30 jours
|
// Vérifier le transit attendu: 20 * 1.50 = 30 jours
|
||||||
expect(economic!.adjustedTransitDays).toBe(30);
|
expect(economic!.adjustedTransitDays).toBe(30);
|
||||||
expect(economic!.transitAdjustmentPercent).toBe(50);
|
expect(economic!.transitAdjustmentPercent).toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('STANDARD doit avoir le transit time de base (pas d\'ajustement)', () => {
|
it("STANDARD doit avoir le transit time de base (pas d'ajustement)", () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
|
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||||
|
|
||||||
// STANDARD doit avoir le transit time de base
|
// STANDARD doit avoir le transit time de base
|
||||||
expect(standard!.adjustedTransitDays).toBe(20);
|
expect(standard!.adjustedTransitDays).toBe(20);
|
||||||
expect(standard!.transitAdjustmentPercent).toBe(0);
|
expect(standard!.transitAdjustmentPercent).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('les offres doivent être triées par prix croissant (ECONOMIC -> STANDARD -> RAPID)', () => {
|
it('les offres doivent être triées par prix croissant (ECONOMIC -> STANDARD -> RAPID)', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
|
|
||||||
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
||||||
expect(offers[1].serviceLevel).toBe(ServiceLevel.STANDARD);
|
expect(offers[1].serviceLevel).toBe(ServiceLevel.STANDARD);
|
||||||
expect(offers[2].serviceLevel).toBe(ServiceLevel.RAPID);
|
expect(offers[2].serviceLevel).toBe(ServiceLevel.RAPID);
|
||||||
|
|
||||||
// Vérifier que les prix sont dans l'ordre croissant
|
// Vérifier que les prix sont dans l'ordre croissant
|
||||||
expect(offers[0].adjustedPriceUSD).toBeLessThan(offers[1].adjustedPriceUSD);
|
expect(offers[0].adjustedPriceUSD).toBeLessThan(offers[1].adjustedPriceUSD);
|
||||||
expect(offers[1].adjustedPriceUSD).toBeLessThan(offers[2].adjustedPriceUSD);
|
expect(offers[1].adjustedPriceUSD).toBeLessThan(offers[2].adjustedPriceUSD);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doit conserver les informations originales du tarif', () => {
|
it('doit conserver les informations originales du tarif', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
|
|
||||||
for (const offer of offers) {
|
for (const offer of offers) {
|
||||||
expect(offer.rate).toBe(mockRate);
|
expect(offer.rate).toBe(mockRate);
|
||||||
expect(offer.originalPriceUSD).toBe(1000);
|
expect(offer.originalPriceUSD).toBe(1000);
|
||||||
expect(offer.originalPriceEUR).toBe(900);
|
expect(offer.originalPriceEUR).toBe(900);
|
||||||
expect(offer.originalTransitDays).toBe(20);
|
expect(offer.originalTransitDays).toBe(20);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doit appliquer la contrainte de transit time minimum (5 jours)', () => {
|
it('doit appliquer la contrainte de transit time minimum (5 jours)', () => {
|
||||||
// Tarif avec transit time très court (3 jours)
|
// Tarif avec transit time très court (3 jours)
|
||||||
const shortTransitRate = {
|
const shortTransitRate = {
|
||||||
...mockRate,
|
...mockRate,
|
||||||
transitDays: 3,
|
transitDays: 3,
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const offers = service.generateOffers(shortTransitRate);
|
const offers = service.generateOffers(shortTransitRate);
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
||||||
|
|
||||||
// RAPID avec 3 * 0.70 = 2.1 jours, mais minimum est 5 jours
|
// RAPID avec 3 * 0.70 = 2.1 jours, mais minimum est 5 jours
|
||||||
expect(rapid!.adjustedTransitDays).toBe(5);
|
expect(rapid!.adjustedTransitDays).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doit appliquer la contrainte de transit time maximum (90 jours)', () => {
|
it('doit appliquer la contrainte de transit time maximum (90 jours)', () => {
|
||||||
// Tarif avec transit time très long (80 jours)
|
// Tarif avec transit time très long (80 jours)
|
||||||
const longTransitRate = {
|
const longTransitRate = {
|
||||||
...mockRate,
|
...mockRate,
|
||||||
transitDays: 80,
|
transitDays: 80,
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const offers = service.generateOffers(longTransitRate);
|
const offers = service.generateOffers(longTransitRate);
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
||||||
|
|
||||||
// ECONOMIC avec 80 * 1.50 = 120 jours, mais maximum est 90 jours
|
// ECONOMIC avec 80 * 1.50 = 120 jours, mais maximum est 90 jours
|
||||||
expect(economic!.adjustedTransitDays).toBe(90);
|
expect(economic!.adjustedTransitDays).toBe(90);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateOffersForRates', () => {
|
describe('generateOffersForRates', () => {
|
||||||
it('doit générer 3 offres par tarif', () => {
|
it('doit générer 3 offres par tarif', () => {
|
||||||
const rate1 = mockRate;
|
const rate1 = mockRate;
|
||||||
const rate2 = {
|
const rate2 = {
|
||||||
...mockRate,
|
...mockRate,
|
||||||
companyName: 'Another Carrier',
|
companyName: 'Another Carrier',
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const offers = service.generateOffersForRates([rate1, rate2]);
|
const offers = service.generateOffersForRates([rate1, rate2]);
|
||||||
|
|
||||||
expect(offers).toHaveLength(6); // 2 tarifs * 3 offres
|
expect(offers).toHaveLength(6); // 2 tarifs * 3 offres
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doit trier toutes les offres par prix croissant', () => {
|
it('doit trier toutes les offres par prix croissant', () => {
|
||||||
const rate1 = mockRate; // Prix base: 1000 USD
|
const rate1 = mockRate; // Prix base: 1000 USD
|
||||||
const rate2 = {
|
const rate2 = {
|
||||||
...mockRate,
|
...mockRate,
|
||||||
companyName: 'Cheaper Carrier',
|
companyName: 'Cheaper Carrier',
|
||||||
pricing: {
|
pricing: {
|
||||||
...mockRate.pricing,
|
...mockRate.pricing,
|
||||||
basePriceUSD: Money.create(500, 'USD'), // Prix base plus bas
|
basePriceUSD: Money.create(500, 'USD'), // Prix base plus bas
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const offers = service.generateOffersForRates([rate1, rate2]);
|
const offers = service.generateOffersForRates([rate1, rate2]);
|
||||||
|
|
||||||
// Vérifier que les prix sont triés
|
// Vérifier que les prix sont triés
|
||||||
for (let i = 0; i < offers.length - 1; i++) {
|
for (let i = 0; i < offers.length - 1; i++) {
|
||||||
expect(offers[i].adjustedPriceUSD).toBeLessThanOrEqual(offers[i + 1].adjustedPriceUSD);
|
expect(offers[i].adjustedPriceUSD).toBeLessThanOrEqual(offers[i + 1].adjustedPriceUSD);
|
||||||
}
|
}
|
||||||
|
|
||||||
// L'offre la moins chère devrait être ECONOMIC du rate2
|
// L'offre la moins chère devrait être ECONOMIC du rate2
|
||||||
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
||||||
expect(offers[0].rate.companyName).toBe('Cheaper Carrier');
|
expect(offers[0].rate.companyName).toBe('Cheaper Carrier');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateOffersForServiceLevel', () => {
|
describe('generateOffersForServiceLevel', () => {
|
||||||
it('doit générer uniquement les offres RAPID', () => {
|
it('doit générer uniquement les offres RAPID', () => {
|
||||||
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.RAPID);
|
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.RAPID);
|
||||||
|
|
||||||
expect(offers).toHaveLength(1);
|
expect(offers).toHaveLength(1);
|
||||||
expect(offers[0].serviceLevel).toBe(ServiceLevel.RAPID);
|
expect(offers[0].serviceLevel).toBe(ServiceLevel.RAPID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doit générer uniquement les offres ECONOMIC', () => {
|
it('doit générer uniquement les offres ECONOMIC', () => {
|
||||||
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.ECONOMIC);
|
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.ECONOMIC);
|
||||||
|
|
||||||
expect(offers).toHaveLength(1);
|
expect(offers).toHaveLength(1);
|
||||||
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getCheapestOffer', () => {
|
describe('getCheapestOffer', () => {
|
||||||
it('doit retourner l\'offre ECONOMIC la moins chère', () => {
|
it("doit retourner l'offre ECONOMIC la moins chère", () => {
|
||||||
const rate1 = mockRate; // 1000 USD base
|
const rate1 = mockRate; // 1000 USD base
|
||||||
const rate2 = {
|
const rate2 = {
|
||||||
...mockRate,
|
...mockRate,
|
||||||
pricing: {
|
pricing: {
|
||||||
...mockRate.pricing,
|
...mockRate.pricing,
|
||||||
basePriceUSD: Money.create(500, 'USD'),
|
basePriceUSD: Money.create(500, 'USD'),
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const cheapest = service.getCheapestOffer([rate1, rate2]);
|
const cheapest = service.getCheapestOffer([rate1, rate2]);
|
||||||
|
|
||||||
expect(cheapest).not.toBeNull();
|
expect(cheapest).not.toBeNull();
|
||||||
expect(cheapest!.serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
expect(cheapest!.serviceLevel).toBe(ServiceLevel.ECONOMIC);
|
||||||
// 500 * 0.85 = 425 USD
|
// 500 * 0.85 = 425 USD
|
||||||
expect(cheapest!.adjustedPriceUSD).toBe(425);
|
expect(cheapest!.adjustedPriceUSD).toBe(425);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doit retourner null si aucun tarif', () => {
|
it('doit retourner null si aucun tarif', () => {
|
||||||
const cheapest = service.getCheapestOffer([]);
|
const cheapest = service.getCheapestOffer([]);
|
||||||
expect(cheapest).toBeNull();
|
expect(cheapest).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getFastestOffer', () => {
|
describe('getFastestOffer', () => {
|
||||||
it('doit retourner l\'offre RAPID la plus rapide', () => {
|
it("doit retourner l'offre RAPID la plus rapide", () => {
|
||||||
const rate1 = { ...mockRate, transitDays: 20 } as any;
|
const rate1 = { ...mockRate, transitDays: 20 } as any;
|
||||||
const rate2 = { ...mockRate, transitDays: 10 } as any;
|
const rate2 = { ...mockRate, transitDays: 10 } as any;
|
||||||
|
|
||||||
const fastest = service.getFastestOffer([rate1, rate2]);
|
const fastest = service.getFastestOffer([rate1, rate2]);
|
||||||
|
|
||||||
expect(fastest).not.toBeNull();
|
expect(fastest).not.toBeNull();
|
||||||
expect(fastest!.serviceLevel).toBe(ServiceLevel.RAPID);
|
expect(fastest!.serviceLevel).toBe(ServiceLevel.RAPID);
|
||||||
// 10 * 0.70 = 7 jours
|
// 10 * 0.70 = 7 jours
|
||||||
expect(fastest!.adjustedTransitDays).toBe(7);
|
expect(fastest!.adjustedTransitDays).toBe(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doit retourner null si aucun tarif', () => {
|
it('doit retourner null si aucun tarif', () => {
|
||||||
const fastest = service.getFastestOffer([]);
|
const fastest = service.getFastestOffer([]);
|
||||||
expect(fastest).toBeNull();
|
expect(fastest).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getBestOffersPerServiceLevel', () => {
|
describe('getBestOffersPerServiceLevel', () => {
|
||||||
it('doit retourner la meilleure offre de chaque niveau de service', () => {
|
it('doit retourner la meilleure offre de chaque niveau de service', () => {
|
||||||
const rate1 = mockRate;
|
const rate1 = mockRate;
|
||||||
const rate2 = {
|
const rate2 = {
|
||||||
...mockRate,
|
...mockRate,
|
||||||
pricing: {
|
pricing: {
|
||||||
...mockRate.pricing,
|
...mockRate.pricing,
|
||||||
basePriceUSD: Money.create(800, 'USD'),
|
basePriceUSD: Money.create(800, 'USD'),
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const best = service.getBestOffersPerServiceLevel([rate1, rate2]);
|
const best = service.getBestOffersPerServiceLevel([rate1, rate2]);
|
||||||
|
|
||||||
expect(best.rapid).not.toBeNull();
|
expect(best.rapid).not.toBeNull();
|
||||||
expect(best.standard).not.toBeNull();
|
expect(best.standard).not.toBeNull();
|
||||||
expect(best.economic).not.toBeNull();
|
expect(best.economic).not.toBeNull();
|
||||||
|
|
||||||
// Toutes doivent provenir du rate2 (moins cher)
|
// Toutes doivent provenir du rate2 (moins cher)
|
||||||
expect(best.rapid!.originalPriceUSD).toBe(800);
|
expect(best.rapid!.originalPriceUSD).toBe(800);
|
||||||
expect(best.standard!.originalPriceUSD).toBe(800);
|
expect(best.standard!.originalPriceUSD).toBe(800);
|
||||||
expect(best.economic!.originalPriceUSD).toBe(800);
|
expect(best.economic!.originalPriceUSD).toBe(800);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isRateEligible', () => {
|
describe('isRateEligible', () => {
|
||||||
it('doit accepter un tarif valide', () => {
|
it('doit accepter un tarif valide', () => {
|
||||||
expect(service.isRateEligible(mockRate)).toBe(true);
|
expect(service.isRateEligible(mockRate)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doit rejeter un tarif avec transit time = 0', () => {
|
it('doit rejeter un tarif avec transit time = 0', () => {
|
||||||
const invalidRate = { ...mockRate, transitDays: 0 } as any;
|
const invalidRate = { ...mockRate, transitDays: 0 } as any;
|
||||||
expect(service.isRateEligible(invalidRate)).toBe(false);
|
expect(service.isRateEligible(invalidRate)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doit rejeter un tarif avec prix = 0', () => {
|
it('doit rejeter un tarif avec prix = 0', () => {
|
||||||
const invalidRate = {
|
const invalidRate = {
|
||||||
...mockRate,
|
...mockRate,
|
||||||
pricing: {
|
pricing: {
|
||||||
...mockRate.pricing,
|
...mockRate.pricing,
|
||||||
basePriceUSD: Money.create(0, 'USD'),
|
basePriceUSD: Money.create(0, 'USD'),
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
expect(service.isRateEligible(invalidRate)).toBe(false);
|
expect(service.isRateEligible(invalidRate)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doit rejeter un tarif expiré', () => {
|
it('doit rejeter un tarif expiré', () => {
|
||||||
const expiredRate = {
|
const expiredRate = {
|
||||||
...mockRate,
|
...mockRate,
|
||||||
isValidForDate: () => false,
|
isValidForDate: () => false,
|
||||||
} as any;
|
} as any;
|
||||||
expect(service.isRateEligible(expiredRate)).toBe(false);
|
expect(service.isRateEligible(expiredRate)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('filterEligibleRates', () => {
|
describe('filterEligibleRates', () => {
|
||||||
it('doit filtrer les tarifs invalides', () => {
|
it('doit filtrer les tarifs invalides', () => {
|
||||||
const validRate = mockRate;
|
const validRate = mockRate;
|
||||||
const invalidRate1 = { ...mockRate, transitDays: 0 } as any;
|
const invalidRate1 = { ...mockRate, transitDays: 0 } as any;
|
||||||
const invalidRate2 = {
|
const invalidRate2 = {
|
||||||
...mockRate,
|
...mockRate,
|
||||||
pricing: {
|
pricing: {
|
||||||
...mockRate.pricing,
|
...mockRate.pricing,
|
||||||
basePriceUSD: Money.create(0, 'USD'),
|
basePriceUSD: Money.create(0, 'USD'),
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const eligibleRates = service.filterEligibleRates([validRate, invalidRate1, invalidRate2]);
|
const eligibleRates = service.filterEligibleRates([validRate, invalidRate1, invalidRate2]);
|
||||||
|
|
||||||
expect(eligibleRates).toHaveLength(1);
|
expect(eligibleRates).toHaveLength(1);
|
||||||
expect(eligibleRates[0]).toBe(validRate);
|
expect(eligibleRates[0]).toBe(validRate);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Validation de la logique métier', () => {
|
describe('Validation de la logique métier', () => {
|
||||||
it('RAPID doit TOUJOURS être plus cher que ECONOMIC', () => {
|
it('RAPID doit TOUJOURS être plus cher que ECONOMIC', () => {
|
||||||
// Test avec différents prix de base
|
// Test avec différents prix de base
|
||||||
const prices = [100, 500, 1000, 5000, 10000];
|
const prices = [100, 500, 1000, 5000, 10000];
|
||||||
|
|
||||||
for (const price of prices) {
|
for (const price of prices) {
|
||||||
const rate = {
|
const rate = {
|
||||||
...mockRate,
|
...mockRate,
|
||||||
pricing: {
|
pricing: {
|
||||||
...mockRate.pricing,
|
...mockRate.pricing,
|
||||||
basePriceUSD: Money.create(price, 'USD'),
|
basePriceUSD: Money.create(price, 'USD'),
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const offers = service.generateOffers(rate);
|
const offers = service.generateOffers(rate);
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||||
|
|
||||||
expect(rapid.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
|
expect(rapid.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('RAPID doit TOUJOURS être plus rapide que ECONOMIC', () => {
|
it('RAPID doit TOUJOURS être plus rapide que ECONOMIC', () => {
|
||||||
// Test avec différents transit times de base
|
// Test avec différents transit times de base
|
||||||
const transitDays = [5, 10, 20, 30, 60];
|
const transitDays = [5, 10, 20, 30, 60];
|
||||||
|
|
||||||
for (const days of transitDays) {
|
for (const days of transitDays) {
|
||||||
const rate = { ...mockRate, transitDays: days } as any;
|
const rate = { ...mockRate, transitDays: days } as any;
|
||||||
|
|
||||||
const offers = service.generateOffers(rate);
|
const offers = service.generateOffers(rate);
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||||
|
|
||||||
expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
|
expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le prix', () => {
|
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le prix', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||||
|
|
||||||
expect(standard.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
|
expect(standard.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
|
||||||
expect(standard.adjustedPriceUSD).toBeLessThan(rapid.adjustedPriceUSD);
|
expect(standard.adjustedPriceUSD).toBeLessThan(rapid.adjustedPriceUSD);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le transit time', () => {
|
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le transit time', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
||||||
|
|
||||||
expect(standard.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
|
expect(standard.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
|
||||||
expect(standard.adjustedTransitDays).toBeGreaterThan(rapid.adjustedTransitDays);
|
expect(standard.adjustedTransitDays).toBeGreaterThan(rapid.adjustedTransitDays);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,257 +1,255 @@
|
|||||||
import { CsvRate } from '../entities/csv-rate.entity';
|
import { CsvRate } from '../entities/csv-rate.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service Level Types
|
* Service Level Types
|
||||||
*
|
*
|
||||||
* - RAPID: Offre la plus chère + la plus rapide (transit time réduit)
|
* - RAPID: Offre la plus chère + la plus rapide (transit time réduit)
|
||||||
* - STANDARD: Offre standard (prix et transit time de base)
|
* - STANDARD: Offre standard (prix et transit time de base)
|
||||||
* - ECONOMIC: Offre la moins chère + la plus lente (transit time augmenté)
|
* - ECONOMIC: Offre la moins chère + la plus lente (transit time augmenté)
|
||||||
*/
|
*/
|
||||||
export enum ServiceLevel {
|
export enum ServiceLevel {
|
||||||
RAPID = 'RAPID',
|
RAPID = 'RAPID',
|
||||||
STANDARD = 'STANDARD',
|
STANDARD = 'STANDARD',
|
||||||
ECONOMIC = 'ECONOMIC',
|
ECONOMIC = 'ECONOMIC',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate Offer - Variante d'un tarif avec un niveau de service
|
* Rate Offer - Variante d'un tarif avec un niveau de service
|
||||||
*/
|
*/
|
||||||
export interface RateOffer {
|
export interface RateOffer {
|
||||||
rate: CsvRate;
|
rate: CsvRate;
|
||||||
serviceLevel: ServiceLevel;
|
serviceLevel: ServiceLevel;
|
||||||
adjustedPriceUSD: number;
|
adjustedPriceUSD: number;
|
||||||
adjustedPriceEUR: number;
|
adjustedPriceEUR: number;
|
||||||
adjustedTransitDays: number;
|
adjustedTransitDays: number;
|
||||||
originalPriceUSD: number;
|
originalPriceUSD: number;
|
||||||
originalPriceEUR: number;
|
originalPriceEUR: number;
|
||||||
originalTransitDays: number;
|
originalTransitDays: number;
|
||||||
priceAdjustmentPercent: number;
|
priceAdjustmentPercent: number;
|
||||||
transitAdjustmentPercent: number;
|
transitAdjustmentPercent: number;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration pour les ajustements de prix et transit par niveau de service
|
* Configuration pour les ajustements de prix et transit par niveau de service
|
||||||
*/
|
*/
|
||||||
interface ServiceLevelConfig {
|
interface ServiceLevelConfig {
|
||||||
priceMultiplier: number; // Multiplicateur de prix (1.0 = pas de changement)
|
priceMultiplier: number; // Multiplicateur de prix (1.0 = pas de changement)
|
||||||
transitMultiplier: number; // Multiplicateur de transit time (1.0 = pas de changement)
|
transitMultiplier: number; // Multiplicateur de transit time (1.0 = pas de changement)
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate Offer Generator Service
|
* Rate Offer Generator Service
|
||||||
*
|
*
|
||||||
* Service du domaine qui génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV.
|
* Service du domaine qui génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV.
|
||||||
*
|
*
|
||||||
* Règles métier:
|
* Règles métier:
|
||||||
* - RAPID : Prix +20%, Transit -30% (plus cher, plus rapide)
|
* - RAPID : Prix +20%, Transit -30% (plus cher, plus rapide)
|
||||||
* - STANDARD : Prix +0%, Transit +0% (tarif de base)
|
* - STANDARD : Prix +0%, Transit +0% (tarif de base)
|
||||||
* - ECONOMIC : Prix -15%, Transit +50% (moins cher, plus lent)
|
* - ECONOMIC : Prix -15%, Transit +50% (moins cher, plus lent)
|
||||||
*
|
*
|
||||||
* Pure domain logic - Pas de dépendances framework
|
* Pure domain logic - Pas de dépendances framework
|
||||||
*/
|
*/
|
||||||
export class RateOfferGeneratorService {
|
export class RateOfferGeneratorService {
|
||||||
/**
|
/**
|
||||||
* Configuration par défaut des niveaux de service
|
* Configuration par défaut des niveaux de service
|
||||||
* Ces valeurs peuvent être ajustées selon les besoins métier
|
* Ces valeurs peuvent être ajustées selon les besoins métier
|
||||||
*/
|
*/
|
||||||
private readonly SERVICE_LEVEL_CONFIGS: Record<ServiceLevel, ServiceLevelConfig> = {
|
private readonly SERVICE_LEVEL_CONFIGS: Record<ServiceLevel, ServiceLevelConfig> = {
|
||||||
[ServiceLevel.RAPID]: {
|
[ServiceLevel.RAPID]: {
|
||||||
priceMultiplier: 1.20, // +20% du prix de base
|
priceMultiplier: 1.2, // +20% du prix de base
|
||||||
transitMultiplier: 0.70, // -30% du temps de transit (plus rapide)
|
transitMultiplier: 0.7, // -30% du temps de transit (plus rapide)
|
||||||
description: 'Express - Livraison rapide avec service prioritaire',
|
description: 'Express - Livraison rapide avec service prioritaire',
|
||||||
},
|
},
|
||||||
[ServiceLevel.STANDARD]: {
|
[ServiceLevel.STANDARD]: {
|
||||||
priceMultiplier: 1.00, // Prix de base (pas de changement)
|
priceMultiplier: 1.0, // Prix de base (pas de changement)
|
||||||
transitMultiplier: 1.00, // Transit time 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',
|
description: 'Standard - Service régulier au meilleur rapport qualité/prix',
|
||||||
},
|
},
|
||||||
[ServiceLevel.ECONOMIC]: {
|
[ServiceLevel.ECONOMIC]: {
|
||||||
priceMultiplier: 0.85, // -15% du prix de base
|
priceMultiplier: 0.85, // -15% du prix de base
|
||||||
transitMultiplier: 1.50, // +50% du temps de transit (plus lent)
|
transitMultiplier: 1.5, // +50% du temps de transit (plus lent)
|
||||||
description: 'Économique - Tarif réduit avec délai étendu',
|
description: 'Économique - Tarif réduit avec délai étendu',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transit time minimum (en jours) pour garantir la cohérence
|
* 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
|
* Même avec réduction, on ne peut pas descendre en dessous de ce minimum
|
||||||
*/
|
*/
|
||||||
private readonly MIN_TRANSIT_DAYS = 5;
|
private readonly MIN_TRANSIT_DAYS = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transit time maximum (en jours) pour garantir la cohérence
|
* Transit time maximum (en jours) pour garantir la cohérence
|
||||||
* Même avec augmentation, on ne peut pas dépasser ce maximum
|
* Même avec augmentation, on ne peut pas dépasser ce maximum
|
||||||
*/
|
*/
|
||||||
private readonly MAX_TRANSIT_DAYS = 90;
|
private readonly MAX_TRANSIT_DAYS = 90;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV
|
* Génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV
|
||||||
*
|
*
|
||||||
* @param rate - Le tarif CSV de base
|
* @param rate - Le tarif CSV de base
|
||||||
* @returns Tableau de 3 offres triées par prix croissant (ECONOMIC, STANDARD, RAPID)
|
* @returns Tableau de 3 offres triées par prix croissant (ECONOMIC, STANDARD, RAPID)
|
||||||
*/
|
*/
|
||||||
generateOffers(rate: CsvRate): RateOffer[] {
|
generateOffers(rate: CsvRate): RateOffer[] {
|
||||||
const offers: RateOffer[] = [];
|
const offers: RateOffer[] = [];
|
||||||
|
|
||||||
// Extraire les prix de base
|
// Extraire les prix de base
|
||||||
const basePriceUSD = rate.pricing.basePriceUSD.getAmount();
|
const basePriceUSD = rate.pricing.basePriceUSD.getAmount();
|
||||||
const basePriceEUR = rate.pricing.basePriceEUR.getAmount();
|
const basePriceEUR = rate.pricing.basePriceEUR.getAmount();
|
||||||
const baseTransitDays = rate.transitDays;
|
const baseTransitDays = rate.transitDays;
|
||||||
|
|
||||||
// Générer les 3 offres
|
// Générer les 3 offres
|
||||||
for (const serviceLevel of Object.values(ServiceLevel)) {
|
for (const serviceLevel of Object.values(ServiceLevel)) {
|
||||||
const config = this.SERVICE_LEVEL_CONFIGS[serviceLevel];
|
const config = this.SERVICE_LEVEL_CONFIGS[serviceLevel];
|
||||||
|
|
||||||
// Calculer les prix ajustés
|
// Calculer les prix ajustés
|
||||||
const adjustedPriceUSD = this.roundPrice(basePriceUSD * config.priceMultiplier);
|
const adjustedPriceUSD = this.roundPrice(basePriceUSD * config.priceMultiplier);
|
||||||
const adjustedPriceEUR = this.roundPrice(basePriceEUR * config.priceMultiplier);
|
const adjustedPriceEUR = this.roundPrice(basePriceEUR * config.priceMultiplier);
|
||||||
|
|
||||||
// Calculer le transit time ajusté (avec contraintes min/max)
|
// Calculer le transit time ajusté (avec contraintes min/max)
|
||||||
const rawTransitDays = baseTransitDays * config.transitMultiplier;
|
const rawTransitDays = baseTransitDays * config.transitMultiplier;
|
||||||
const adjustedTransitDays = this.constrainTransitDays(
|
const adjustedTransitDays = this.constrainTransitDays(Math.round(rawTransitDays));
|
||||||
Math.round(rawTransitDays)
|
|
||||||
);
|
// Calculer les pourcentages d'ajustement
|
||||||
|
const priceAdjustmentPercent = Math.round((config.priceMultiplier - 1) * 100);
|
||||||
// Calculer les pourcentages d'ajustement
|
const transitAdjustmentPercent = Math.round((config.transitMultiplier - 1) * 100);
|
||||||
const priceAdjustmentPercent = Math.round((config.priceMultiplier - 1) * 100);
|
|
||||||
const transitAdjustmentPercent = Math.round((config.transitMultiplier - 1) * 100);
|
offers.push({
|
||||||
|
rate,
|
||||||
offers.push({
|
serviceLevel,
|
||||||
rate,
|
adjustedPriceUSD,
|
||||||
serviceLevel,
|
adjustedPriceEUR,
|
||||||
adjustedPriceUSD,
|
adjustedTransitDays,
|
||||||
adjustedPriceEUR,
|
originalPriceUSD: basePriceUSD,
|
||||||
adjustedTransitDays,
|
originalPriceEUR: basePriceEUR,
|
||||||
originalPriceUSD: basePriceUSD,
|
originalTransitDays: baseTransitDays,
|
||||||
originalPriceEUR: basePriceEUR,
|
priceAdjustmentPercent,
|
||||||
originalTransitDays: baseTransitDays,
|
transitAdjustmentPercent,
|
||||||
priceAdjustmentPercent,
|
description: config.description,
|
||||||
transitAdjustmentPercent,
|
});
|
||||||
description: config.description,
|
}
|
||||||
});
|
|
||||||
}
|
// Trier par prix croissant: ECONOMIC (moins cher) -> STANDARD -> RAPID (plus cher)
|
||||||
|
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
|
||||||
// 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
|
||||||
/**
|
*
|
||||||
* 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
|
||||||
* @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[] = [];
|
||||||
generateOffersForRates(rates: CsvRate[]): RateOffer[] {
|
|
||||||
const allOffers: RateOffer[] = [];
|
for (const rate of rates) {
|
||||||
|
const offers = this.generateOffers(rate);
|
||||||
for (const rate of rates) {
|
allOffers.push(...offers);
|
||||||
const offers = this.generateOffers(rate);
|
}
|
||||||
allOffers.push(...offers);
|
|
||||||
}
|
// Trier toutes les offres par prix croissant
|
||||||
|
return allOffers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
|
||||||
// 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
|
||||||
/**
|
*
|
||||||
* 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)
|
||||||
* @param rates - Liste de tarifs CSV
|
* @returns Liste des offres du niveau de service demandé, triées par prix
|
||||||
* @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[] = [];
|
||||||
generateOffersForServiceLevel(rates: CsvRate[], serviceLevel: ServiceLevel): RateOffer[] {
|
|
||||||
const offers: RateOffer[] = [];
|
for (const rate of rates) {
|
||||||
|
const allOffers = this.generateOffers(rate);
|
||||||
for (const rate of rates) {
|
const matchingOffer = allOffers.find(o => o.serviceLevel === serviceLevel);
|
||||||
const allOffers = this.generateOffers(rate);
|
|
||||||
const matchingOffer = allOffers.find(o => o.serviceLevel === serviceLevel);
|
if (matchingOffer) {
|
||||||
|
offers.push(matchingOffer);
|
||||||
if (matchingOffer) {
|
}
|
||||||
offers.push(matchingOffer);
|
}
|
||||||
}
|
|
||||||
}
|
// Trier par prix croissant
|
||||||
|
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
|
||||||
// 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
|
||||||
/**
|
*/
|
||||||
* Obtient l'offre la moins chère (ECONOMIC) parmi une liste de tarifs
|
getCheapestOffer(rates: CsvRate[]): RateOffer | null {
|
||||||
*/
|
if (rates.length === 0) return null;
|
||||||
getCheapestOffer(rates: CsvRate[]): RateOffer | null {
|
|
||||||
if (rates.length === 0) return null;
|
const economicOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC);
|
||||||
|
return economicOffers[0] || null;
|
||||||
const economicOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC);
|
}
|
||||||
return economicOffers[0] || null;
|
|
||||||
}
|
/**
|
||||||
|
* Obtient l'offre la plus rapide (RAPID) parmi une liste de tarifs
|
||||||
/**
|
*/
|
||||||
* Obtient l'offre la plus rapide (RAPID) parmi une liste de tarifs
|
getFastestOffer(rates: CsvRate[]): RateOffer | null {
|
||||||
*/
|
if (rates.length === 0) return null;
|
||||||
getFastestOffer(rates: CsvRate[]): RateOffer | null {
|
|
||||||
if (rates.length === 0) return null;
|
const rapidOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID);
|
||||||
|
|
||||||
const rapidOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID);
|
// Trier par transit time croissant (plus rapide en premier)
|
||||||
|
rapidOffers.sort((a, b) => a.adjustedTransitDays - b.adjustedTransitDays);
|
||||||
// Trier par transit time croissant (plus rapide en premier)
|
|
||||||
rapidOffers.sort((a, b) => a.adjustedTransitDays - b.adjustedTransitDays);
|
return rapidOffers[0] || null;
|
||||||
|
}
|
||||||
return rapidOffers[0] || null;
|
|
||||||
}
|
/**
|
||||||
|
* Obtient les meilleures offres (meilleur rapport qualité/prix)
|
||||||
/**
|
* Retourne une offre de chaque niveau de service avec le meilleur prix
|
||||||
* 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;
|
||||||
getBestOffersPerServiceLevel(rates: CsvRate[]): {
|
standard: RateOffer | null;
|
||||||
rapid: RateOffer | null;
|
economic: RateOffer | null;
|
||||||
standard: RateOffer | null;
|
} {
|
||||||
economic: RateOffer | null;
|
return {
|
||||||
} {
|
rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] || null,
|
||||||
return {
|
standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] || null,
|
||||||
rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] || null,
|
economic: this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC)[0] || null,
|
||||||
standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] || null,
|
};
|
||||||
economic: this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC)[0] || null,
|
}
|
||||||
};
|
|
||||||
}
|
/**
|
||||||
|
* Arrondit le prix à 2 décimales
|
||||||
/**
|
*/
|
||||||
* Arrondit le prix à 2 décimales
|
private roundPrice(price: number): number {
|
||||||
*/
|
return Math.round(price * 100) / 100;
|
||||||
private roundPrice(price: number): number {
|
}
|
||||||
return Math.round(price * 100) / 100;
|
|
||||||
}
|
/**
|
||||||
|
* Contraint le transit time entre les limites min et max
|
||||||
/**
|
*/
|
||||||
* 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));
|
||||||
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
|
||||||
/**
|
*
|
||||||
* Vérifie si un tarif est éligible pour la génération d'offres
|
* Critères:
|
||||||
*
|
* - Transit time doit être > 0
|
||||||
* Critères:
|
* - Prix doit être > 0
|
||||||
* - Transit time doit être > 0
|
* - Tarif doit être valide (non expiré)
|
||||||
* - Prix doit être > 0
|
*/
|
||||||
* - Tarif doit être valide (non expiré)
|
isRateEligible(rate: CsvRate): boolean {
|
||||||
*/
|
if (rate.transitDays <= 0) return false;
|
||||||
isRateEligible(rate: CsvRate): boolean {
|
if (rate.pricing.basePriceUSD.getAmount() <= 0) return false;
|
||||||
if (rate.transitDays <= 0) return false;
|
if (!rate.isValidForDate(new Date())) return false;
|
||||||
if (rate.pricing.basePriceUSD.getAmount() <= 0) return false;
|
|
||||||
if (!rate.isValidForDate(new Date())) return false;
|
return true;
|
||||||
|
}
|
||||||
return true;
|
|
||||||
}
|
/**
|
||||||
|
* Filtre les tarifs éligibles pour la génération d'offres
|
||||||
/**
|
*/
|
||||||
* Filtre les tarifs éligibles pour la génération d'offres
|
filterEligibleRates(rates: CsvRate[]): CsvRate[] {
|
||||||
*/
|
return rates.filter(rate => this.isRateEligible(rate));
|
||||||
filterEligibleRates(rates: CsvRate[]): CsvRate[] {
|
}
|
||||||
return rates.filter(rate => this.isRateEligible(rate));
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -90,8 +90,14 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadRatesFromCsv(filePath: string, companyEmail: string, companyNameOverride?: string): Promise<CsvRate[]> {
|
async loadRatesFromCsv(
|
||||||
this.logger.log(`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`);
|
filePath: string,
|
||||||
|
companyEmail: string,
|
||||||
|
companyNameOverride?: string
|
||||||
|
): Promise<CsvRate[]> {
|
||||||
|
this.logger.log(
|
||||||
|
`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let fileContent: string;
|
let fileContent: string;
|
||||||
@ -114,7 +120,9 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
throw new Error('No MinIO object key found, using local file');
|
throw new Error('No MinIO object key found, using local file');
|
||||||
}
|
}
|
||||||
} catch (minioError: any) {
|
} 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
|
// Fallback to local file system
|
||||||
const fullPath = path.isAbsolute(filePath)
|
const fullPath = path.isAbsolute(filePath)
|
||||||
? 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');
|
this.logger.warn('⚠️ No CSV files configured in MinIO, falling back to local files');
|
||||||
}
|
}
|
||||||
} catch (minioError: any) {
|
} 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
|
* 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
|
// Parse surcharges
|
||||||
const surcharges = this.parseSurcharges(record);
|
const surcharges = this.parseSurcharges(record);
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ export class CarrierActivityOrmEntity {
|
|||||||
@Column({ name: 'carrier_id', type: 'uuid' })
|
@Column({ name: 'carrier_id', type: 'uuid' })
|
||||||
carrierId: string;
|
carrierId: string;
|
||||||
|
|
||||||
@ManyToOne(() => CarrierProfileOrmEntity, (carrier) => carrier.activities, {
|
@ManyToOne(() => CarrierProfileOrmEntity, carrier => carrier.activities, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
@JoinColumn({ name: 'carrier_id' })
|
@JoinColumn({ name: 'carrier_id' })
|
||||||
|
|||||||
@ -118,9 +118,9 @@ export class CarrierProfileOrmEntity {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
@OneToMany(() => CsvBookingOrmEntity, (booking) => booking.carrierProfile)
|
@OneToMany(() => CsvBookingOrmEntity, booking => booking.carrierProfile)
|
||||||
bookings: CsvBookingOrmEntity[];
|
bookings: CsvBookingOrmEntity[];
|
||||||
|
|
||||||
@OneToMany(() => CarrierActivityOrmEntity, (activity) => activity.carrierProfile)
|
@OneToMany(() => CarrierActivityOrmEntity, activity => activity.carrierProfile)
|
||||||
activities: CarrierActivityOrmEntity[];
|
activities: CarrierActivityOrmEntity[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,7 +113,7 @@ export class CsvBookingOrmEntity {
|
|||||||
@Column({ name: 'carrier_id', type: 'uuid', nullable: true })
|
@Column({ name: 'carrier_id', type: 'uuid', nullable: true })
|
||||||
carrierId: string | null;
|
carrierId: string | null;
|
||||||
|
|
||||||
@ManyToOne(() => CarrierProfileOrmEntity, (carrier) => carrier.bookings, {
|
@ManyToOne(() => CarrierProfileOrmEntity, carrier => carrier.bookings, {
|
||||||
onDelete: 'SET NULL',
|
onDelete: 'SET NULL',
|
||||||
})
|
})
|
||||||
@JoinColumn({ name: 'carrier_id' })
|
@JoinColumn({ name: 'carrier_id' })
|
||||||
|
|||||||
@ -16,7 +16,8 @@ export class SeedTestUsers1730000000007 implements MigrationInterface {
|
|||||||
|
|
||||||
// Pre-hashed password: Password123! (Argon2id)
|
// Pre-hashed password: Password123! (Argon2id)
|
||||||
// Generated with: argon2.hash('Password123!', { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 4 })
|
// 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)
|
// Fixed UUIDs for test users (matching existing data in database)
|
||||||
const users = [
|
const users = [
|
||||||
|
|||||||
@ -7,7 +7,10 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { CarrierActivityOrmEntity, CarrierActivityType } from '../entities/carrier-activity.orm-entity';
|
import {
|
||||||
|
CarrierActivityOrmEntity,
|
||||||
|
CarrierActivityType,
|
||||||
|
} from '../entities/carrier-activity.orm-entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CarrierActivityRepository {
|
export class CarrierActivityRepository {
|
||||||
@ -27,7 +30,9 @@ export class CarrierActivityRepository {
|
|||||||
ipAddress?: string | null;
|
ipAddress?: string | null;
|
||||||
userAgent?: string | null;
|
userAgent?: string | null;
|
||||||
}): Promise<CarrierActivityOrmEntity> {
|
}): Promise<CarrierActivityOrmEntity> {
|
||||||
this.logger.log(`Creating carrier activity: ${data.activityType} for carrier ${data.carrierId}`);
|
this.logger.log(
|
||||||
|
`Creating carrier activity: ${data.activityType} for carrier ${data.carrierId}`
|
||||||
|
);
|
||||||
|
|
||||||
const activity = this.repository.create(data);
|
const activity = this.repository.create(data);
|
||||||
const saved = await this.repository.save(activity);
|
const saved = await this.repository.save(activity);
|
||||||
@ -36,7 +41,10 @@ export class CarrierActivityRepository {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByCarrierId(carrierId: string, limit: number = 10): Promise<CarrierActivityOrmEntity[]> {
|
async findByCarrierId(
|
||||||
|
carrierId: string,
|
||||||
|
limit: number = 10
|
||||||
|
): Promise<CarrierActivityOrmEntity[]> {
|
||||||
this.logger.log(`Finding activities for carrier: ${carrierId} (limit: ${limit})`);
|
this.logger.log(`Finding activities for carrier: ${carrierId} (limit: ${limit})`);
|
||||||
|
|
||||||
const activities = await this.repository.find({
|
const activities = await this.repository.find({
|
||||||
@ -74,7 +82,9 @@ export class CarrierActivityRepository {
|
|||||||
take: limit,
|
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;
|
return activities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -76,7 +76,10 @@ export class CarrierProfileRepository {
|
|||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, data: Partial<CarrierProfileOrmEntity>): Promise<CarrierProfileOrmEntity> {
|
async update(
|
||||||
|
id: string,
|
||||||
|
data: Partial<CarrierProfileOrmEntity>
|
||||||
|
): Promise<CarrierProfileOrmEntity> {
|
||||||
this.logger.log(`Updating carrier profile: ${id}`);
|
this.logger.log(`Updating carrier profile: ${id}`);
|
||||||
|
|
||||||
await this.repository.update(id, data);
|
await this.repository.update(id, data);
|
||||||
@ -131,7 +134,9 @@ export class CarrierProfileRepository {
|
|||||||
relations: ['user', 'organization'],
|
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;
|
return profiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,42 +1,43 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from '../app.module';
|
import { AppModule } from '../app.module';
|
||||||
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Script to delete orphaned CSV rate configuration
|
* Script to delete orphaned CSV rate configuration
|
||||||
* Usage: npm run ts-node src/scripts/delete-orphaned-csv-config.ts
|
* Usage: npm run ts-node src/scripts/delete-orphaned-csv-config.ts
|
||||||
*/
|
*/
|
||||||
async function deleteOrphanedConfig() {
|
async function deleteOrphanedConfig() {
|
||||||
const app = await NestFactory.createApplicationContext(AppModule);
|
const app = await NestFactory.createApplicationContext(AppModule);
|
||||||
const repository = app.get(TypeOrmCsvRateConfigRepository);
|
const repository = app.get(TypeOrmCsvRateConfigRepository);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('🔍 Searching for orphaned test.csv configuration...');
|
console.log('🔍 Searching for orphaned test.csv configuration...');
|
||||||
|
|
||||||
const configs = await repository.findAll();
|
const configs = await repository.findAll();
|
||||||
const orphanedConfig = configs.find((c) => c.csvFilePath === 'test.csv');
|
const orphanedConfig = configs.find(c => c.csvFilePath === 'test.csv');
|
||||||
|
|
||||||
if (!orphanedConfig) {
|
if (!orphanedConfig) {
|
||||||
console.log('✅ No orphaned test.csv configuration found');
|
console.log('✅ No orphaned test.csv configuration found');
|
||||||
await app.close();
|
await app.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📄 Found orphaned config: ${orphanedConfig.companyName} - ${orphanedConfig.csvFilePath}`);
|
console.log(
|
||||||
console.log(` ID: ${orphanedConfig.id}`);
|
`📄 Found orphaned config: ${orphanedConfig.companyName} - ${orphanedConfig.csvFilePath}`
|
||||||
console.log(` Uploaded: ${orphanedConfig.uploadedAt}`);
|
);
|
||||||
|
console.log(` ID: ${orphanedConfig.id}`);
|
||||||
// Delete the orphaned configuration
|
console.log(` Uploaded: ${orphanedConfig.uploadedAt}`);
|
||||||
await repository.delete(orphanedConfig.companyName);
|
|
||||||
|
// Delete the orphaned configuration
|
||||||
console.log('✅ Successfully deleted orphaned test.csv configuration');
|
await repository.delete(orphanedConfig.companyName);
|
||||||
|
|
||||||
} catch (error: any) {
|
console.log('✅ Successfully deleted orphaned test.csv configuration');
|
||||||
console.error('❌ Error deleting orphaned config:', error.message);
|
} catch (error: any) {
|
||||||
process.exit(1);
|
console.error('❌ Error deleting orphaned config:', error.message);
|
||||||
}
|
process.exit(1);
|
||||||
|
}
|
||||||
await app.close();
|
|
||||||
}
|
await app.close();
|
||||||
|
}
|
||||||
deleteOrphanedConfig();
|
|
||||||
|
deleteOrphanedConfig();
|
||||||
|
|||||||
@ -1,118 +1,118 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from '../app.module';
|
import { AppModule } from '../app.module';
|
||||||
import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter';
|
import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter';
|
||||||
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Script to migrate existing CSV files to MinIO
|
* Script to migrate existing CSV files to MinIO
|
||||||
* Usage: npm run ts-node src/scripts/migrate-csv-to-minio.ts
|
* Usage: npm run ts-node src/scripts/migrate-csv-to-minio.ts
|
||||||
*/
|
*/
|
||||||
async function migrateCsvFilesToMinio() {
|
async function migrateCsvFilesToMinio() {
|
||||||
const app = await NestFactory.createApplicationContext(AppModule);
|
const app = await NestFactory.createApplicationContext(AppModule);
|
||||||
const s3Storage = app.get(S3StorageAdapter);
|
const s3Storage = app.get(S3StorageAdapter);
|
||||||
const csvConfigRepository = app.get(TypeOrmCsvRateConfigRepository);
|
const csvConfigRepository = app.get(TypeOrmCsvRateConfigRepository);
|
||||||
const configService = app.get(ConfigService);
|
const configService = app.get(ConfigService);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('🚀 Starting CSV migration to MinIO...\n');
|
console.log('🚀 Starting CSV migration to MinIO...\n');
|
||||||
|
|
||||||
const bucket = configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates');
|
const bucket = configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates');
|
||||||
const csvDirectory = path.join(
|
const csvDirectory = path.join(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
'src',
|
'src',
|
||||||
'infrastructure',
|
'infrastructure',
|
||||||
'storage',
|
'storage',
|
||||||
'csv-storage',
|
'csv-storage',
|
||||||
'rates'
|
'rates'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get all CSV configurations
|
// Get all CSV configurations
|
||||||
const configs = await csvConfigRepository.findAll();
|
const configs = await csvConfigRepository.findAll();
|
||||||
console.log(`📋 Found ${configs.length} CSV configurations\n`);
|
console.log(`📋 Found ${configs.length} CSV configurations\n`);
|
||||||
|
|
||||||
let migratedCount = 0;
|
let migratedCount = 0;
|
||||||
let skippedCount = 0;
|
let skippedCount = 0;
|
||||||
let errorCount = 0;
|
let errorCount = 0;
|
||||||
|
|
||||||
for (const config of configs) {
|
for (const config of configs) {
|
||||||
const filename = config.csvFilePath;
|
const filename = config.csvFilePath;
|
||||||
const filePath = path.join(csvDirectory, filename);
|
const filePath = path.join(csvDirectory, filename);
|
||||||
|
|
||||||
console.log(`📄 Processing: ${config.companyName} - ${filename}`);
|
console.log(`📄 Processing: ${config.companyName} - ${filename}`);
|
||||||
|
|
||||||
// Check if already in MinIO
|
// Check if already in MinIO
|
||||||
const existingMinioKey = config.metadata?.minioObjectKey as string | undefined;
|
const existingMinioKey = config.metadata?.minioObjectKey as string | undefined;
|
||||||
if (existingMinioKey) {
|
if (existingMinioKey) {
|
||||||
console.log(` ⏭️ Already in MinIO: ${existingMinioKey}`);
|
console.log(` ⏭️ Already in MinIO: ${existingMinioKey}`);
|
||||||
skippedCount++;
|
skippedCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists locally
|
// Check if file exists locally
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
console.log(` ⚠️ Local file not found: ${filePath}`);
|
console.log(` ⚠️ Local file not found: ${filePath}`);
|
||||||
errorCount++;
|
errorCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Read local file
|
// Read local file
|
||||||
const fileBuffer = fs.readFileSync(filePath);
|
const fileBuffer = fs.readFileSync(filePath);
|
||||||
const objectKey = `csv-rates/${filename}`;
|
const objectKey = `csv-rates/${filename}`;
|
||||||
|
|
||||||
// Upload to MinIO
|
// Upload to MinIO
|
||||||
await s3Storage.upload({
|
await s3Storage.upload({
|
||||||
bucket,
|
bucket,
|
||||||
key: objectKey,
|
key: objectKey,
|
||||||
body: fileBuffer,
|
body: fileBuffer,
|
||||||
contentType: 'text/csv',
|
contentType: 'text/csv',
|
||||||
metadata: {
|
metadata: {
|
||||||
companyName: config.companyName,
|
companyName: config.companyName,
|
||||||
uploadedBy: 'migration-script',
|
uploadedBy: 'migration-script',
|
||||||
migratedAt: new Date().toISOString(),
|
migratedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update configuration with MinIO object key
|
// Update configuration with MinIO object key
|
||||||
await csvConfigRepository.update(config.id, {
|
await csvConfigRepository.update(config.id, {
|
||||||
metadata: {
|
metadata: {
|
||||||
...config.metadata,
|
...config.metadata,
|
||||||
minioObjectKey: objectKey,
|
minioObjectKey: objectKey,
|
||||||
migratedToMinioAt: new Date().toISOString(),
|
migratedToMinioAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(` ✅ Uploaded to MinIO: ${bucket}/${objectKey}`);
|
console.log(` ✅ Uploaded to MinIO: ${bucket}/${objectKey}`);
|
||||||
migratedCount++;
|
migratedCount++;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(` ❌ Error uploading ${filename}: ${error.message}`);
|
console.log(` ❌ Error uploading ${filename}: ${error.message}`);
|
||||||
errorCount++;
|
errorCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n' + '='.repeat(60));
|
console.log('\n' + '='.repeat(60));
|
||||||
console.log('📊 Migration Summary:');
|
console.log('📊 Migration Summary:');
|
||||||
console.log(` ✅ Migrated: ${migratedCount}`);
|
console.log(` ✅ Migrated: ${migratedCount}`);
|
||||||
console.log(` ⏭️ Skipped (already in MinIO): ${skippedCount}`);
|
console.log(` ⏭️ Skipped (already in MinIO): ${skippedCount}`);
|
||||||
console.log(` ❌ Errors: ${errorCount}`);
|
console.log(` ❌ Errors: ${errorCount}`);
|
||||||
console.log('='.repeat(60) + '\n');
|
console.log('='.repeat(60) + '\n');
|
||||||
|
|
||||||
if (migratedCount > 0) {
|
if (migratedCount > 0) {
|
||||||
console.log('🎉 Migration completed successfully!');
|
console.log('🎉 Migration completed successfully!');
|
||||||
} else if (skippedCount === configs.length) {
|
} else if (skippedCount === configs.length) {
|
||||||
console.log('✅ All files are already in MinIO');
|
console.log('✅ All files are already in MinIO');
|
||||||
} else {
|
} else {
|
||||||
console.log('⚠️ Migration completed with errors');
|
console.log('⚠️ Migration completed with errors');
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('❌ Migration failed:', error.message);
|
console.error('❌ Migration failed:', error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
migrateCsvFilesToMinio();
|
migrateCsvFilesToMinio();
|
||||||
|
|||||||
@ -116,9 +116,7 @@ describe('Carrier Portal (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 401 without auth token', () => {
|
it('should return 401 without auth token', () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer()).get('/api/v1/carrier-auth/me').expect(401);
|
||||||
.get('/api/v1/carrier-auth/me')
|
|
||||||
.expect(401);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -169,9 +167,7 @@ describe('Carrier Portal (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return 401 without auth token', () => {
|
it('should return 401 without auth token', () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer()).get('/api/v1/carrier-dashboard/stats').expect(401);
|
||||||
.get('/api/v1/carrier-dashboard/stats')
|
|
||||||
.expect(401);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,390 +1,352 @@
|
|||||||
/**
|
/**
|
||||||
* Dashboard Home Page
|
* Dashboard Home Page
|
||||||
*
|
*
|
||||||
* Main dashboard with KPIs, charts, and alerts
|
* Main dashboard with CSV Booking KPIs and carrier analytics
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { dashboardApi, listBookings } from '@/lib/api';
|
import { dashboardApi } from '@/lib/api';
|
||||||
import Link from 'next/link';
|
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 {
|
import {
|
||||||
LineChart,
|
Package,
|
||||||
Line,
|
PackageCheck,
|
||||||
BarChart,
|
PackageX,
|
||||||
Bar,
|
Clock,
|
||||||
XAxis,
|
Weight,
|
||||||
YAxis,
|
TrendingUp,
|
||||||
CartesianGrid,
|
Plus,
|
||||||
Tooltip,
|
ArrowRight,
|
||||||
Legend,
|
} from 'lucide-react';
|
||||||
ResponsiveContainer,
|
|
||||||
} from 'recharts';
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
// Fetch dashboard data
|
// Fetch CSV booking KPIs
|
||||||
const { data: kpis, isLoading: kpisLoading } = useQuery({
|
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
|
||||||
queryKey: ['dashboard', 'kpis'],
|
queryKey: ['dashboard', 'csv-booking-kpis'],
|
||||||
queryFn: () => dashboardApi.getKPIs(),
|
queryFn: () => dashboardApi.getCsvBookingKPIs(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: chartData, isLoading: chartLoading } = useQuery({
|
// Fetch top carriers
|
||||||
queryKey: ['dashboard', 'bookings-chart'],
|
const { data: topCarriers, isLoading: carriersLoading } = useQuery({
|
||||||
queryFn: () => dashboardApi.getBookingsChart(),
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8 p-8">
|
||||||
{/* Welcome Section */}
|
{/* Header Section */}
|
||||||
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg shadow-lg p-6 text-white">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-3xl font-bold mb-2">Welcome back!</h1>
|
<div>
|
||||||
<p className="text-blue-100">Here's what's happening with your shipments today.</p>
|
<h1 className="text-4xl font-bold tracking-tight">Dashboard</h1>
|
||||||
</div>
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Suivez vos bookings et vos performances
|
||||||
{/* KPI Cards */}
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{kpisLoading ? (
|
|
||||||
// Loading skeletons
|
|
||||||
Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<div key={i} className="bg-white rounded-lg shadow p-6 animate-pulse">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4"></div>
|
|
||||||
<div className="h-8 bg-gray-200 rounded w-3/4"></div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">Bookings This Month</p>
|
|
||||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
|
||||||
{kpis?.bookingsThisMonth || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-4xl">📦</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<span
|
|
||||||
className={`text-sm font-medium ${getChangeColor(
|
|
||||||
kpis?.bookingsThisMonthChange || 0
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
{formatChange(kpis?.bookingsThisMonthChange || 0)}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-500 ml-2">vs last month</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">Total TEUs</p>
|
|
||||||
<p className="text-3xl font-bold text-gray-900 mt-2">{kpis?.totalTEUs || 0}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-4xl">📊</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<span
|
|
||||||
className={`text-sm font-medium ${getChangeColor(kpis?.totalTEUsChange || 0)}`}
|
|
||||||
>
|
|
||||||
{formatChange(kpis?.totalTEUsChange || 0)}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-500 ml-2">vs last month</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">Estimated Revenue</p>
|
|
||||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
|
||||||
${(kpis?.estimatedRevenue || 0).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-4xl">💰</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<span
|
|
||||||
className={`text-sm font-medium ${getChangeColor(
|
|
||||||
kpis?.estimatedRevenueChange || 0
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
{formatChange(kpis?.estimatedRevenueChange || 0)}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-500 ml-2">vs last month</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-600">Pending Confirmations</p>
|
|
||||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
|
||||||
{kpis?.pendingConfirmations || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-4xl">⏳</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<span
|
|
||||||
className={`text-sm font-medium ${getChangeColor(
|
|
||||||
kpis?.pendingConfirmationsChange || 0
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
{formatChange(kpis?.pendingConfirmationsChange || 0)}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-gray-500 ml-2">vs last month</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alerts Section */}
|
|
||||||
{!alertsLoading && alerts && alerts.length > 0 && (
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">⚠️ Alerts & Notifications</h2>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{alerts.slice(0, 5).map(alert => (
|
|
||||||
<div
|
|
||||||
key={alert.id}
|
|
||||||
className={`border-l-4 p-4 rounded ${getAlertColor(alert.severity)}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="font-semibold">{alert.title}</h3>
|
|
||||||
<p className="text-sm mt-1">{alert.message}</p>
|
|
||||||
{alert.bookingNumber && (
|
|
||||||
<Link
|
|
||||||
href={`/dashboard/bookings/${alert.bookingId}`}
|
|
||||||
className="text-sm font-medium underline mt-2 inline-block"
|
|
||||||
>
|
|
||||||
View Booking {alert.bookingNumber}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-medium uppercase ml-4">{alert.severity}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Charts Section */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* Bookings Trend Chart */}
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Bookings Trend (6 Months)</h2>
|
|
||||||
{chartLoading ? (
|
|
||||||
<div className="h-64 bg-gray-100 animate-pulse rounded"></div>
|
|
||||||
) : (
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<LineChart data={formattedChartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="month" />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
<Line type="monotone" dataKey="bookings" stroke="#3b82f6" strokeWidth={2} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Trade Lanes Chart */}
|
{/* CTA Button */}
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Link href="/dashboard/bookings/new">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Top 5 Trade Lanes</h2>
|
<Button size="lg" className="gap-2">
|
||||||
{tradeLanesLoading ? (
|
<Plus className="h-5 w-5" />
|
||||||
<div className="h-64 bg-gray-100 animate-pulse rounded"></div>
|
Nouveau Booking
|
||||||
) : (
|
</Button>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<BarChart data={tradeLanes}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="route" angle={-45} textAnchor="end" height={100} />
|
|
||||||
<YAxis />
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
<Bar dataKey="bookingCount" fill="#3b82f6" name="Bookings" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/search"
|
|
||||||
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow group"
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center text-2xl group-hover:bg-blue-200 transition-colors">
|
|
||||||
🔍
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Search Rates</h3>
|
|
||||||
<p className="text-sm text-gray-500">Find the best shipping rates</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/dashboard/bookings/new"
|
|
||||||
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow group"
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center text-2xl group-hover:bg-green-200 transition-colors">
|
|
||||||
➕
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">New Booking</h3>
|
|
||||||
<p className="text-sm text-gray-500">Create a new shipment</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/dashboard/bookings"
|
|
||||||
className="bg-white rounded-lg shadow p-6 hover:shadow-md transition-shadow group"
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center text-2xl group-hover:bg-purple-200 transition-colors">
|
|
||||||
📋
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">My Bookings</h3>
|
|
||||||
<p className="text-sm text-gray-500">View all your shipments</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Bookings */}
|
{/* KPI Cards Grid */}
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
{/* Bookings Acceptés */}
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Recent Bookings</h2>
|
<Card className="hover:shadow-lg transition-shadow">
|
||||||
<Link href="/dashboard/bookings" className="text-sm text-blue-600 hover:text-blue-800">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
View All →
|
<CardTitle className="text-sm font-medium">Bookings Acceptés</CardTitle>
|
||||||
</Link>
|
<PackageCheck className="h-5 w-5 text-green-600" />
|
||||||
</div>
|
</CardHeader>
|
||||||
<div className="p-6">
|
<CardContent>
|
||||||
{bookingsLoading ? (
|
{csvKpisLoading ? (
|
||||||
|
<div className="h-8 w-20 bg-gray-200 animate-pulse rounded" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-3xl font-bold">{csvKpis?.totalAccepted || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
+{csvKpis?.acceptedThisMonth || 0} ce mois
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bookings Refusés */}
|
||||||
|
<Card className="hover:shadow-lg transition-shadow">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Bookings Refusés</CardTitle>
|
||||||
|
<PackageX className="h-5 w-5 text-red-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{csvKpisLoading ? (
|
||||||
|
<div className="h-8 w-20 bg-gray-200 animate-pulse rounded" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-3xl font-bold">{csvKpis?.totalRejected || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
+{csvKpis?.rejectedThisMonth || 0} ce mois
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Bookings En Attente */}
|
||||||
|
<Card className="hover:shadow-lg transition-shadow">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">En Attente</CardTitle>
|
||||||
|
<Clock className="h-5 w-5 text-yellow-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{csvKpisLoading ? (
|
||||||
|
<div className="h-8 w-20 bg-gray-200 animate-pulse rounded" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-3xl font-bold">{csvKpis?.totalPending || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{csvKpis?.acceptanceRate.toFixed(1)}% taux d'acceptation
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Poids Total Accepté */}
|
||||||
|
<Card className="hover:shadow-lg transition-shadow">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Poids Total Accepté</CardTitle>
|
||||||
|
<Weight className="h-5 w-5 text-blue-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{csvKpisLoading ? (
|
||||||
|
<div className="h-8 w-20 bg-gray-200 animate-pulse rounded" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-3xl font-bold">
|
||||||
|
{(csvKpis?.totalWeightAcceptedKG || 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
KG ({(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(2)} CBM)
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Overview Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Vue d'ensemble</CardTitle>
|
||||||
|
<CardDescription>Statistiques globales de vos bookings</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<TrendingUp className="h-6 w-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Taux d'acceptation</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{csvKpisLoading ? '--' : `${csvKpis?.acceptanceRate.toFixed(1)}%`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<Package className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Total bookings</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{csvKpisLoading
|
||||||
|
? '--'
|
||||||
|
: (csvKpis?.totalAccepted || 0) +
|
||||||
|
(csvKpis?.totalRejected || 0) +
|
||||||
|
(csvKpis?.totalPending || 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-purple-100">
|
||||||
|
<Weight className="h-6 w-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Volume total accepté</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`}
|
||||||
|
<span className="text-sm font-normal text-muted-foreground ml-1">CBM</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Top Carriers Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Meilleures Compagnies</CardTitle>
|
||||||
|
<CardDescription>Top 5 des transporteurs avec qui vous avez le plus booké</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard/bookings">
|
||||||
|
<Button variant="ghost" size="sm" className="gap-2">
|
||||||
|
Voir tous
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{carriersLoading ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div key={i} className="h-16 bg-gray-100 animate-pulse rounded"></div>
|
<div key={i} className="h-20 bg-gray-100 animate-pulse rounded" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : recentBookings && recentBookings.bookings.length > 0 ? (
|
) : topCarriers && topCarriers.length > 0 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{recentBookings.bookings.map((booking: any) => (
|
{topCarriers.map((carrier, index) => (
|
||||||
<Link
|
<div
|
||||||
key={booking.id}
|
key={carrier.carrierName}
|
||||||
href={`/dashboard/bookings/${booking.id}`}
|
className="flex items-center justify-between p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||||
className="flex items-center justify-between p-4 hover:bg-gray-50 rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-bold text-primary">
|
||||||
📦
|
#{index + 1}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-gray-900">{booking.bookingNumber}</div>
|
<h3 className="font-semibold">{carrier.carrierName}</h3>
|
||||||
<div className="text-sm text-gray-500">
|
<p className="text-sm text-muted-foreground">
|
||||||
{new Date(booking.createdAt).toLocaleDateString()}
|
{carrier.totalBookings} bookings • {carrier.totalWeightKG.toLocaleString()} KG
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<span
|
<div className="text-right">
|
||||||
className={`px-3 py-1 rounded-full text-xs font-medium ${
|
<div className="flex items-center gap-2">
|
||||||
booking.status === 'confirmed'
|
<Badge variant="secondary" className="bg-green-100 text-green-800">
|
||||||
? 'bg-green-100 text-green-800'
|
{carrier.acceptedBookings} acceptés
|
||||||
: booking.status === 'pending'
|
</Badge>
|
||||||
? 'bg-yellow-100 text-yellow-800'
|
{carrier.rejectedBookings > 0 && (
|
||||||
: 'bg-gray-100 text-gray-800'
|
<Badge variant="secondary" className="bg-red-100 text-red-800">
|
||||||
}`}
|
{carrier.rejectedBookings} refusés
|
||||||
>
|
</Badge>
|
||||||
{booking.status}
|
)}
|
||||||
</span>
|
</div>
|
||||||
<svg
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
className="w-5 h-5 text-gray-400"
|
Taux: {carrier.acceptanceRate.toFixed(0)}% • Moy:{' '}
|
||||||
fill="none"
|
${carrier.avgPriceUSD.toFixed(0)}
|
||||||
stroke="currentColor"
|
</p>
|
||||||
viewBox="0 0 24 24"
|
</div>
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="text-6xl mb-4">📦</div>
|
<Package className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No bookings yet</h3>
|
<h3 className="text-lg font-medium text-gray-900 mb-2">Aucun booking pour l'instant</h3>
|
||||||
<p className="text-gray-500 mb-6">Create your first booking to get started</p>
|
<p className="text-muted-foreground mb-6">
|
||||||
<Link
|
Créez votre premier booking pour voir vos statistiques
|
||||||
href="/dashboard/bookings/new"
|
</p>
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
<Link href="/dashboard/bookings/new">
|
||||||
>
|
<Button>
|
||||||
Create Booking
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Créer un booking
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Link href="/dashboard/search">
|
||||||
|
<Card className="hover:shadow-lg transition-all hover:-translate-y-1 cursor-pointer">
|
||||||
|
<CardContent className="flex items-center space-x-4 p-6">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">Rechercher des tarifs</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Trouver les meilleurs prix</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/dashboard/bookings">
|
||||||
|
<Card className="hover:shadow-lg transition-all hover:-translate-y-1 cursor-pointer">
|
||||||
|
<CardContent className="flex items-center space-x-4 p-6">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-purple-100">
|
||||||
|
<Package className="h-6 w-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">Mes Bookings</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Voir tous mes envois</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/dashboard/settings/organization">
|
||||||
|
<Card className="hover:shadow-lg transition-all hover:-translate-y-1 cursor-pointer">
|
||||||
|
<CardContent className="flex items-center space-x-4 p-6">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">Paramètres</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Configuration du compte</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -83,6 +83,50 @@ export async function getAlerts(): Promise<DashboardAlert[]> {
|
|||||||
return get<DashboardAlert[]>('/api/v1/dashboard/alerts');
|
return get<DashboardAlert[]>('/api/v1/dashboard/alerts');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV Booking KPIs Response
|
||||||
|
*/
|
||||||
|
export interface CsvBookingKPIs {
|
||||||
|
totalAccepted: number;
|
||||||
|
totalRejected: number;
|
||||||
|
totalPending: number;
|
||||||
|
totalWeightAcceptedKG: number;
|
||||||
|
totalVolumeAcceptedCBM: number;
|
||||||
|
acceptanceRate: number;
|
||||||
|
acceptedThisMonth: number;
|
||||||
|
rejectedThisMonth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top Carrier Data
|
||||||
|
*/
|
||||||
|
export interface TopCarrier {
|
||||||
|
carrierName: string;
|
||||||
|
totalBookings: number;
|
||||||
|
acceptedBookings: number;
|
||||||
|
rejectedBookings: number;
|
||||||
|
acceptanceRate: number;
|
||||||
|
totalWeightKG: number;
|
||||||
|
totalVolumeCBM: number;
|
||||||
|
avgPriceUSD: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CSV booking KPIs
|
||||||
|
* GET /api/v1/dashboard/csv-booking-kpis
|
||||||
|
*/
|
||||||
|
export async function getCsvBookingKPIs(): Promise<CsvBookingKPIs> {
|
||||||
|
return get<CsvBookingKPIs>('/api/v1/dashboard/csv-booking-kpis');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top carriers (top 5 by booking count)
|
||||||
|
* GET /api/v1/dashboard/top-carriers
|
||||||
|
*/
|
||||||
|
export async function getTopCarriers(): Promise<TopCarrier[]> {
|
||||||
|
return get<TopCarrier[]>('/api/v1/dashboard/top-carriers');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export all dashboard APIs
|
* Export all dashboard APIs
|
||||||
*/
|
*/
|
||||||
@ -91,4 +135,6 @@ export const dashboardApi = {
|
|||||||
getBookingsChart,
|
getBookingsChart,
|
||||||
getTopTradeLanes,
|
getTopTradeLanes,
|
||||||
getAlerts,
|
getAlerts,
|
||||||
|
getCsvBookingKPIs,
|
||||||
|
getTopCarriers,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user