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