diff --git a/apps/backend/src/application/controllers/rates.controller.ts b/apps/backend/src/application/controllers/rates.controller.ts index 731b297..558b014 100644 --- a/apps/backend/src/application/controllers/rates.controller.ts +++ b/apps/backend/src/application/controllers/rates.controller.ts @@ -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 { + 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 */ diff --git a/apps/backend/src/application/dto/csv-rate-search.dto.ts b/apps/backend/src/application/dto/csv-rate-search.dto.ts index a906079..53d5f42 100644 --- a/apps/backend/src/application/dto/csv-rate-search.dto.ts +++ b/apps/backend/src/application/dto/csv-rate-search.dto.ts @@ -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; } diff --git a/apps/backend/src/application/mappers/csv-rate.mapper.ts b/apps/backend/src/application/mappers/csv-rate.mapper.ts index b145f42..28973c6 100644 --- a/apps/backend/src/application/mappers/csv-rate.mapper.ts +++ b/apps/backend/src/application/mappers/csv-rate.mapper.ts @@ -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, }; } diff --git a/apps/backend/src/domain/ports/in/search-csv-rates.port.ts b/apps/backend/src/domain/ports/in/search-csv-rates.port.ts index d76b57d..7381fd0 100644 --- a/apps/backend/src/domain/ports/in/search-csv-rates.port.ts +++ b/apps/backend/src/domain/ports/in/search-csv-rates.port.ts @@ -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; + /** + * 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; + /** * Get available companies in CSV system * @returns List of company names that have CSV rates diff --git a/apps/backend/src/domain/services/csv-rate-search.service.ts b/apps/backend/src/domain/services/csv-rate-search.service.ts index f3cd6b3..b28ed16 100644 --- a/apps/backend/src/domain/services/csv-rate-search.service.ts +++ b/apps/backend/src/domain/services/csv-rate-search.service.ts @@ -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 { @@ -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 { + 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 { const allRates = await this.loadAllRates(); const companies = new Set(allRates.map(rate => rate.companyName)); diff --git a/apps/backend/src/domain/services/rate-offer-generator.service.spec.ts b/apps/backend/src/domain/services/rate-offer-generator.service.spec.ts new file mode 100644 index 0000000..a512d0f --- /dev/null +++ b/apps/backend/src/domain/services/rate-offer-generator.service.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/backend/src/domain/services/rate-offer-generator.service.ts b/apps/backend/src/domain/services/rate-offer-generator.service.ts new file mode 100644 index 0000000..07c4e89 --- /dev/null +++ b/apps/backend/src/domain/services/rate-offer-generator.service.ts @@ -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.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)); + } +} diff --git a/apps/backend/test-csv-offers-api.sh b/apps/backend/test-csv-offers-api.sh new file mode 100644 index 0000000..7414513 --- /dev/null +++ b/apps/backend/test-csv-offers-api.sh @@ -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 "================================================"