This commit is contained in:
David 2025-12-15 16:51:36 +01:00
parent 49b02face6
commit 368de79a1c
8 changed files with 1199 additions and 1 deletions

View File

@ -196,6 +196,81 @@ export class RatesController {
}
}
/**
* Search CSV-based rates with service level offers (RAPID, STANDARD, ECONOMIC)
*/
@Post('search-csv-offers')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Search CSV-based rates with service level offers',
description:
'Search for rates from CSV-loaded carriers and generate 3 service level offers for each matching rate: RAPID (20% more expensive, 30% faster), STANDARD (base price and transit), ECONOMIC (15% cheaper, 50% slower). Results are sorted by price (cheapest first).',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'CSV rate search with offers completed successfully',
type: CsvRateSearchResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiBadRequestResponse({
description: 'Invalid request parameters',
})
async searchCsvRatesWithOffers(
@Body() dto: CsvRateSearchDto,
@CurrentUser() user: UserPayload
): Promise<CsvRateSearchResponseDto> {
const startTime = Date.now();
this.logger.log(
`[User: ${user.email}] Searching CSV rates with offers: ${dto.origin}${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg`
);
try {
// Map DTO to domain input
const searchInput = {
origin: dto.origin,
destination: dto.destination,
volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG,
palletCount: dto.palletCount ?? 0,
containerType: dto.containerType,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
// Service requirements for detailed pricing
hasDangerousGoods: dto.hasDangerousGoods ?? false,
requiresSpecialHandling: dto.requiresSpecialHandling ?? false,
requiresTailgate: dto.requiresTailgate ?? false,
requiresStraps: dto.requiresStraps ?? false,
requiresThermalCover: dto.requiresThermalCover ?? false,
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
requiresAppointment: dto.requiresAppointment ?? false,
};
// Execute CSV rate search WITH OFFERS GENERATION
const result = await this.csvRateSearchService.executeWithOffers(searchInput);
// Map domain output to response DTO
const response = this.csvRateMapper.mapSearchOutputToResponseDto(result);
const responseTimeMs = Date.now() - startTime;
this.logger.log(
`CSV rate search with offers completed: ${response.totalResults} results (including 3 offers per rate), ${responseTimeMs}ms`
);
return response;
} catch (error: any) {
this.logger.error(
`CSV rate search with offers failed: ${error?.message || 'Unknown error'}`,
error?.stack
);
throw error;
}
}
/**
* Get available companies
*/

View File

@ -369,4 +369,26 @@ export class CsvRateResultDto {
example: 95,
})
matchScore: number;
@ApiPropertyOptional({
description: 'Service level (only present when using search-csv-offers endpoint)',
enum: ['RAPID', 'STANDARD', 'ECONOMIC'],
example: 'RAPID',
})
serviceLevel?: string;
@ApiPropertyOptional({
description: 'Original price before service level adjustment',
example: { usd: 1500.0, eur: 1350.0 },
})
originalPrice?: {
usd: number;
eur: number;
};
@ApiPropertyOptional({
description: 'Original transit days before service level adjustment',
example: 20,
})
originalTransitDays?: number;
}

View File

@ -78,10 +78,15 @@ export class CsvRateMapper {
},
hasSurcharges: rate.hasSurcharges(),
surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null,
transitDays: rate.transitDays,
// Use adjusted transit days if available (service level offers), otherwise use original
transitDays: result.adjustedTransitDays ?? rate.transitDays,
validUntil: rate.validity.getEndDate().toISOString().split('T')[0],
source: result.source,
matchScore: result.matchScore,
// Include service level fields if present
serviceLevel: result.serviceLevel,
originalPrice: result.originalPrice,
originalTransitDays: result.originalTransitDays,
};
}

View File

@ -1,6 +1,7 @@
import { CsvRate } from '../../entities/csv-rate.entity';
import { PortCode } from '../../value-objects/port-code.vo';
import { Volume } from '../../value-objects/volume.vo';
import { ServiceLevel } from '../../services/rate-offer-generator.service';
/**
* Advanced Rate Search Filters
@ -35,6 +36,9 @@ export interface RateSearchFilters {
// Date filters
departureDate?: Date; // Filter by validity for specific date
// Service level filter
serviceLevels?: ServiceLevel[]; // Filter by service level (RAPID, STANDARD, ECONOMIC)
}
/**
@ -100,6 +104,13 @@ export interface CsvRateSearchResult {
priceBreakdown: PriceBreakdown; // Detailed price calculation
source: 'CSV';
matchScore: number; // 0-100, how well it matches filters
serviceLevel?: ServiceLevel; // Service level (RAPID, STANDARD, ECONOMIC) if offers are generated
originalPrice?: {
usd: number;
eur: number;
}; // Original price before service level adjustment
originalTransitDays?: number; // Original transit days before service level adjustment
adjustedTransitDays?: number; // Adjusted transit days (for service level offers)
}
/**
@ -129,6 +140,14 @@ export interface SearchCsvRatesPort {
*/
execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>;
/**
* Execute CSV rate search with service level offers generation
* Generates 3 offers (RAPID, STANDARD, ECONOMIC) for each matching rate
* @param input - Search parameters and filters
* @returns Matching rates with 3 service level variants each
*/
executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>;
/**
* Get available companies in CSV system
* @returns List of company names that have CSV rates

View File

@ -12,6 +12,7 @@ import {
} from '@domain/ports/in/search-csv-rates.port';
import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port';
import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service';
import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.service';
/**
* Config Metadata Interface (to avoid circular dependency)
@ -42,12 +43,14 @@ export interface CsvRateConfigRepositoryPort {
*/
export class CsvRateSearchService implements SearchCsvRatesPort {
private readonly priceCalculator: CsvRatePriceCalculatorService;
private readonly offerGenerator: RateOfferGeneratorService;
constructor(
private readonly csvRateLoader: CsvRateLoaderPort,
private readonly configRepository?: CsvRateConfigRepositoryPort
) {
this.priceCalculator = new CsvRatePriceCalculatorService();
this.offerGenerator = new RateOfferGeneratorService();
}
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
@ -119,6 +122,108 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
};
}
/**
* Execute CSV rate search with service level offers generation
* Generates 3 offers (RAPID, STANDARD, ECONOMIC) for each matching rate
*/
async executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
const searchStartTime = new Date();
// Parse and validate input
const origin = PortCode.create(input.origin);
const destination = PortCode.create(input.destination);
const volume = new Volume(input.volumeCBM, input.weightKG);
const palletCount = input.palletCount ?? 0;
// Load all CSV rates
const allRates = await this.loadAllRates();
// Apply route and volume matching
let matchingRates = this.filterByRoute(allRates, origin, destination);
matchingRates = this.filterByVolume(matchingRates, volume);
matchingRates = this.filterByPalletCount(matchingRates, palletCount);
// Apply container type filter if specified
if (input.containerType) {
const containerType = ContainerType.create(input.containerType);
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
}
// Apply advanced filters (before generating offers)
if (input.filters) {
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume);
}
// Filter eligible rates for offer generation
const eligibleRates = this.offerGenerator.filterEligibleRates(matchingRates);
// Generate 3 offers (RAPID, STANDARD, ECONOMIC) for each eligible rate
const allOffers = this.offerGenerator.generateOffersForRates(eligibleRates);
// Convert offers to search results
const results: CsvRateSearchResult[] = allOffers.map(offer => {
// Calculate detailed price breakdown with adjusted prices
const priceBreakdown = this.priceCalculator.calculatePrice(offer.rate, {
volumeCBM: input.volumeCBM,
weightKG: input.weightKG,
palletCount: input.palletCount ?? 0,
hasDangerousGoods: input.hasDangerousGoods ?? false,
requiresSpecialHandling: input.requiresSpecialHandling ?? false,
requiresTailgate: input.requiresTailgate ?? false,
requiresStraps: input.requiresStraps ?? false,
requiresThermalCover: input.requiresThermalCover ?? false,
hasRegulatedProducts: input.hasRegulatedProducts ?? false,
requiresAppointment: input.requiresAppointment ?? false,
});
// Apply service level price adjustment to the total price
const adjustedTotalPrice = priceBreakdown.totalPrice *
(offer.serviceLevel === ServiceLevel.RAPID ? 1.20 :
offer.serviceLevel === ServiceLevel.ECONOMIC ? 0.85 : 1.00);
return {
rate: offer.rate,
calculatedPrice: {
usd: adjustedTotalPrice,
eur: adjustedTotalPrice, // TODO: Add currency conversion
primaryCurrency: priceBreakdown.currency,
},
priceBreakdown: {
...priceBreakdown,
totalPrice: adjustedTotalPrice,
},
source: 'CSV' as const,
matchScore: this.calculateMatchScore(offer.rate, input),
serviceLevel: offer.serviceLevel,
originalPrice: {
usd: offer.originalPriceUSD,
eur: offer.originalPriceEUR,
},
originalTransitDays: offer.originalTransitDays,
adjustedTransitDays: offer.adjustedTransitDays,
};
});
// Apply service level filter if specified
let filteredResults = results;
if (input.filters?.serviceLevels && input.filters.serviceLevels.length > 0) {
filteredResults = results.filter(r =>
r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel)
);
}
// Sort by total price (ascending) - ECONOMIC first, then STANDARD, then RAPID
filteredResults.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice);
return {
results: filteredResults,
totalResults: filteredResults.length,
searchedFiles: await this.csvRateLoader.getAvailableCsvFiles(),
searchedAt: searchStartTime,
appliedFilters: input.filters || {},
};
}
async getAvailableCompanies(): Promise<string[]> {
const allRates = await this.loadAllRates();
const companies = new Set(allRates.map(rate => rate.companyName));

View File

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

View File

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

View File

@ -0,0 +1,282 @@
#!/bin/bash
# Script de test pour l'API de génération d'offres CSV
# Usage: ./test-csv-offers-api.sh
set -e
echo "================================================"
echo "🧪 Test de l'API - Génération d'Offres CSV"
echo "================================================"
echo ""
# Configuration
API_URL="http://localhost:4000/api/v1"
JWT_TOKEN=""
# Couleurs pour l'output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Fonction pour afficher les résultats
print_step() {
echo -e "${BLUE}${NC} $1"
}
print_success() {
echo -e "${GREEN}${NC} $1"
}
print_error() {
echo -e "${RED}${NC} $1"
}
print_info() {
echo -e "${YELLOW}${NC} $1"
}
# Étape 1: Login
print_step "Étape 1: Authentification"
echo ""
LOGIN_RESPONSE=$(curl -s -X POST "${API_URL}/auth/login" \
-H "Content-Type: application/json" \
-d '{
"email": "admin@xpeditis.com",
"password": "Admin123!"
}')
JWT_TOKEN=$(echo $LOGIN_RESPONSE | jq -r '.accessToken')
if [ "$JWT_TOKEN" != "null" ] && [ ! -z "$JWT_TOKEN" ]; then
print_success "Authentification réussie"
print_info "Token: ${JWT_TOKEN:0:20}..."
else
print_error "Échec de l'authentification"
echo "Réponse: $LOGIN_RESPONSE"
exit 1
fi
echo ""
echo "================================================"
echo ""
# Étape 2: Recherche standard (sans offres)
print_step "Étape 2: Recherche CSV standard (sans offres)"
echo ""
STANDARD_RESPONSE=$(curl -s -X POST "${API_URL}/rates/search-csv" \
-H "Authorization: Bearer ${JWT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"origin": "FRPAR",
"destination": "USNYC",
"volumeCBM": 5.0,
"weightKG": 1000,
"palletCount": 2
}')
STANDARD_RESULTS=$(echo $STANDARD_RESPONSE | jq -r '.totalResults // 0')
print_info "Résultats trouvés (standard): $STANDARD_RESULTS"
if [ "$STANDARD_RESULTS" -gt "0" ]; then
print_success "Recherche standard réussie"
# Afficher le premier résultat
echo ""
print_info "Premier résultat (standard):"
echo $STANDARD_RESPONSE | jq '.results[0] | {
companyName: .rate.companyName,
priceUSD: .calculatedPrice.usd,
transitDays: .rate.transitDays,
serviceLevel: .serviceLevel
}' | sed 's/^/ /'
else
print_error "Aucun résultat trouvé (vérifiez que des tarifs CSV sont chargés)"
fi
echo ""
echo "================================================"
echo ""
# Étape 3: Recherche avec génération d'offres
print_step "Étape 3: Recherche CSV avec génération d'offres (RAPID, STANDARD, ECONOMIC)"
echo ""
OFFERS_RESPONSE=$(curl -s -X POST "${API_URL}/rates/search-csv-offers" \
-H "Authorization: Bearer ${JWT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"origin": "FRPAR",
"destination": "USNYC",
"volumeCBM": 5.0,
"weightKG": 1000,
"palletCount": 2
}')
OFFERS_RESULTS=$(echo $OFFERS_RESPONSE | jq -r '.totalResults // 0')
print_info "Résultats trouvés (avec offres): $OFFERS_RESULTS"
if [ "$OFFERS_RESULTS" -gt "0" ]; then
print_success "Recherche avec offres réussie"
# Vérifier qu'on a bien 3x plus d'offres qu'en standard
EXPECTED_OFFERS=$((STANDARD_RESULTS * 3))
if [ "$OFFERS_RESULTS" -eq "$EXPECTED_OFFERS" ]; then
print_success "Nombre d'offres correct: $OFFERS_RESULTS (= $STANDARD_RESULTS tarifs × 3 offres)"
else
print_info "Offres générées: $OFFERS_RESULTS (attendu: $EXPECTED_OFFERS)"
fi
echo ""
print_info "Première offre de chaque type:"
echo ""
# Afficher une offre ECONOMIC
print_info "📦 Offre ECONOMIC (moins chère + plus lente):"
echo $OFFERS_RESPONSE | jq '.results[] | select(.serviceLevel == "ECONOMIC") | {
companyName: .rate.companyName,
serviceLevel: .serviceLevel,
priceUSD: .calculatedPrice.usd,
originalPriceUSD: .originalPrice.usd,
transitDays: .rate.transitDays,
originalTransitDays: .originalTransitDays,
priceAdjustment: "-15%",
transitAdjustment: "+50%"
}' | head -n 12 | sed 's/^/ /'
echo ""
# Afficher une offre STANDARD
print_info "📦 Offre STANDARD (prix et transit de base):"
echo $OFFERS_RESPONSE | jq '.results[] | select(.serviceLevel == "STANDARD") | {
companyName: .rate.companyName,
serviceLevel: .serviceLevel,
priceUSD: .calculatedPrice.usd,
originalPriceUSD: .originalPrice.usd,
transitDays: .rate.transitDays,
originalTransitDays: .originalTransitDays,
priceAdjustment: "Aucun",
transitAdjustment: "Aucun"
}' | head -n 12 | sed 's/^/ /'
echo ""
# Afficher une offre RAPID
print_info "📦 Offre RAPID (plus chère + plus rapide):"
echo $OFFERS_RESPONSE | jq '.results[] | select(.serviceLevel == "RAPID") | {
companyName: .rate.companyName,
serviceLevel: .serviceLevel,
priceUSD: .calculatedPrice.usd,
originalPriceUSD: .originalPrice.usd,
transitDays: .rate.transitDays,
originalTransitDays: .originalTransitDays,
priceAdjustment: "+20%",
transitAdjustment: "-30%"
}' | head -n 12 | sed 's/^/ /'
echo ""
echo "================================================"
echo ""
# Validation de la logique métier
print_step "Validation de la logique métier"
echo ""
# Extraire les prix et transit pour validation
ECONOMIC_PRICE=$(echo $OFFERS_RESPONSE | jq -r '.results[] | select(.serviceLevel == "ECONOMIC") | .calculatedPrice.usd' | head -1)
STANDARD_PRICE=$(echo $OFFERS_RESPONSE | jq -r '.results[] | select(.serviceLevel == "STANDARD") | .calculatedPrice.usd' | head -1)
RAPID_PRICE=$(echo $OFFERS_RESPONSE | jq -r '.results[] | select(.serviceLevel == "RAPID") | .calculatedPrice.usd' | head -1)
ECONOMIC_TRANSIT=$(echo $OFFERS_RESPONSE | jq -r '.results[] | select(.serviceLevel == "ECONOMIC") | .rate.transitDays' | head -1)
STANDARD_TRANSIT=$(echo $OFFERS_RESPONSE | jq -r '.results[] | select(.serviceLevel == "STANDARD") | .rate.transitDays' | head -1)
RAPID_TRANSIT=$(echo $OFFERS_RESPONSE | jq -r '.results[] | select(.serviceLevel == "RAPID") | .rate.transitDays' | head -1)
# Validation: RAPID plus cher que STANDARD
if (( $(echo "$RAPID_PRICE > $STANDARD_PRICE" | bc -l) )); then
print_success "RAPID plus cher que STANDARD ($RAPID_PRICE > $STANDARD_PRICE) ✓"
else
print_error "RAPID devrait être plus cher que STANDARD"
fi
# Validation: ECONOMIC moins cher que STANDARD
if (( $(echo "$ECONOMIC_PRICE < $STANDARD_PRICE" | bc -l) )); then
print_success "ECONOMIC moins cher que STANDARD ($ECONOMIC_PRICE < $STANDARD_PRICE) ✓"
else
print_error "ECONOMIC devrait être moins cher que STANDARD"
fi
# Validation: RAPID plus rapide que STANDARD
if (( $(echo "$RAPID_TRANSIT < $STANDARD_TRANSIT" | bc -l) )); then
print_success "RAPID plus rapide que STANDARD ($RAPID_TRANSIT < $STANDARD_TRANSIT jours) ✓"
else
print_error "RAPID devrait être plus rapide que STANDARD"
fi
# Validation: ECONOMIC plus lent que STANDARD
if (( $(echo "$ECONOMIC_TRANSIT > $STANDARD_TRANSIT" | bc -l) )); then
print_success "ECONOMIC plus lent que STANDARD ($ECONOMIC_TRANSIT > $STANDARD_TRANSIT jours) ✓"
else
print_error "ECONOMIC devrait être plus lent que STANDARD"
fi
else
print_error "Aucune offre générée"
echo "Réponse: $OFFERS_RESPONSE" | jq '.'
fi
echo ""
echo "================================================"
echo ""
# Étape 4: Test avec filtre de niveau de service
print_step "Étape 4: Test avec filtre de niveau de service (RAPID uniquement)"
echo ""
FILTERED_RESPONSE=$(curl -s -X POST "${API_URL}/rates/search-csv-offers" \
-H "Authorization: Bearer ${JWT_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"origin": "FRPAR",
"destination": "USNYC",
"volumeCBM": 5.0,
"weightKG": 1000,
"palletCount": 2,
"filters": {
"serviceLevels": ["RAPID"]
}
}')
FILTERED_RESULTS=$(echo $FILTERED_RESPONSE | jq -r '.totalResults // 0')
print_info "Résultats filtrés (RAPID uniquement): $FILTERED_RESULTS"
if [ "$FILTERED_RESULTS" -gt "0" ]; then
print_success "Filtre de niveau de service fonctionne"
# Vérifier que toutes les offres sont RAPID
NON_RAPID=$(echo $FILTERED_RESPONSE | jq -r '.results[] | select(.serviceLevel != "RAPID") | .serviceLevel' | wc -l)
if [ "$NON_RAPID" -eq "0" ]; then
print_success "Toutes les offres sont de niveau RAPID ✓"
else
print_error "Certaines offres ne sont pas de niveau RAPID"
fi
else
print_error "Aucun résultat avec filtre RAPID"
fi
echo ""
echo "================================================"
echo ""
print_success "Tests terminés avec succès!"
echo ""
print_info "Pour tester dans Swagger UI:"
print_info " → http://localhost:4000/api/docs"
print_info " → Endpoint: POST /api/v1/rates/search-csv-offers"
echo ""
print_info "Documentation complète:"
print_info " → ALGO_BOOKING_CSV_IMPLEMENTATION.md"
echo ""
echo "================================================"