feature dashboard

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,8 @@ import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
import { BookingRepository } from '@domain/ports/out/booking.repository'; import { BookingRepository } from '@domain/ports/out/booking.repository';
import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository'; import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository';
import { RateQuoteRepository } from '@domain/ports/out/rate-quote.repository'; import { RateQuoteRepository } from '@domain/ports/out/rate-quote.repository';
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import { CsvBooking, CsvBookingStatus } from '@domain/entities/csv-booking.entity';
export interface DashboardKPIs { export interface DashboardKPIs {
bookingsThisMonth: number; bookingsThisMonth: number;
@ -47,13 +49,36 @@ export interface DashboardAlert {
isRead: boolean; isRead: boolean;
} }
export interface CsvBookingKPIs {
totalAccepted: number;
totalRejected: number;
totalPending: number;
totalWeightAcceptedKG: number;
totalVolumeAcceptedCBM: number;
acceptanceRate: number; // percentage
acceptedThisMonth: number;
rejectedThisMonth: number;
}
export interface TopCarrier {
carrierName: string;
totalBookings: number;
acceptedBookings: number;
rejectedBookings: number;
acceptanceRate: number;
totalWeightKG: number;
totalVolumeCBM: number;
avgPriceUSD: number;
}
@Injectable() @Injectable()
export class AnalyticsService { export class AnalyticsService {
constructor( constructor(
@Inject(BOOKING_REPOSITORY) @Inject(BOOKING_REPOSITORY)
private readonly bookingRepository: BookingRepository, private readonly bookingRepository: BookingRepository,
@Inject(RATE_QUOTE_REPOSITORY) @Inject(RATE_QUOTE_REPOSITORY)
private readonly rateQuoteRepository: RateQuoteRepository private readonly rateQuoteRepository: RateQuoteRepository,
private readonly csvBookingRepository: TypeOrmCsvBookingRepository
) {} ) {}
/** /**
@ -307,4 +332,133 @@ export class AnalyticsService {
return alerts; return alerts;
} }
/**
* Get CSV Booking KPIs
*/
async getCsvBookingKPIs(organizationId: string): Promise<CsvBookingKPIs> {
const allCsvBookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
const now = new Date();
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
// Filter by status
const acceptedBookings = allCsvBookings.filter(
(b: CsvBooking) => b.status === CsvBookingStatus.ACCEPTED
);
const rejectedBookings = allCsvBookings.filter(
(b: CsvBooking) => b.status === CsvBookingStatus.REJECTED
);
const pendingBookings = allCsvBookings.filter(
(b: CsvBooking) => b.status === CsvBookingStatus.PENDING
);
// This month stats
const acceptedThisMonth = acceptedBookings.filter(
(b: CsvBooking) => b.requestedAt >= thisMonthStart
).length;
const rejectedThisMonth = rejectedBookings.filter(
(b: CsvBooking) => b.requestedAt >= thisMonthStart
).length;
// Calculate total weight and volume for accepted bookings
const totalWeightAcceptedKG = acceptedBookings.reduce(
(sum: number, b: CsvBooking) => sum + b.weightKG,
0
);
const totalVolumeAcceptedCBM = acceptedBookings.reduce(
(sum: number, b: CsvBooking) => sum + b.volumeCBM,
0
);
// Calculate acceptance rate
const totalProcessed = acceptedBookings.length + rejectedBookings.length;
const acceptanceRate =
totalProcessed > 0 ? (acceptedBookings.length / totalProcessed) * 100 : 0;
return {
totalAccepted: acceptedBookings.length,
totalRejected: rejectedBookings.length,
totalPending: pendingBookings.length,
totalWeightAcceptedKG,
totalVolumeAcceptedCBM,
acceptanceRate,
acceptedThisMonth,
rejectedThisMonth,
};
}
/**
* Get Top Carriers by booking count
*/
async getTopCarriers(organizationId: string, limit: number = 5): Promise<TopCarrier[]> {
const allCsvBookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
// Group by carrier
const carrierMap = new Map<
string,
{
totalBookings: number;
acceptedBookings: number;
rejectedBookings: number;
totalWeightKG: number;
totalVolumeCBM: number;
totalPriceUSD: number;
}
>();
for (const booking of allCsvBookings) {
const carrierName = booking.carrierName;
if (!carrierMap.has(carrierName)) {
carrierMap.set(carrierName, {
totalBookings: 0,
acceptedBookings: 0,
rejectedBookings: 0,
totalWeightKG: 0,
totalVolumeCBM: 0,
totalPriceUSD: 0,
});
}
const carrier = carrierMap.get(carrierName)!;
carrier.totalBookings++;
if (booking.status === CsvBookingStatus.ACCEPTED) {
carrier.acceptedBookings++;
carrier.totalWeightKG += booking.weightKG;
carrier.totalVolumeCBM += booking.volumeCBM;
}
if (booking.status === CsvBookingStatus.REJECTED) {
carrier.rejectedBookings++;
}
// Add price (prefer USD, fallback to EUR converted)
if (booking.priceUSD) {
carrier.totalPriceUSD += booking.priceUSD;
} else if (booking.priceEUR) {
// Simple EUR to USD conversion (1.1 rate) - in production, use real exchange rate
carrier.totalPriceUSD += booking.priceEUR * 1.1;
}
}
// Convert to array
const topCarriers: TopCarrier[] = Array.from(carrierMap.entries()).map(
([carrierName, data]) => ({
carrierName,
totalBookings: data.totalBookings,
acceptedBookings: data.acceptedBookings,
rejectedBookings: data.rejectedBookings,
acceptanceRate:
data.totalBookings > 0 ? (data.acceptedBookings / data.totalBookings) * 100 : 0,
totalWeightKG: data.totalWeightKG,
totalVolumeCBM: data.totalVolumeCBM,
avgPriceUSD: data.totalBookings > 0 ? data.totalPriceUSD / data.totalBookings : 0,
})
);
// Sort by total bookings (most bookings first)
return topCarriers.sort((a, b) => b.totalBookings - a.totalBookings).slice(0, limit);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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