merge
This commit is contained in:
parent
49b02face6
commit
368de79a1c
@ -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
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
257
apps/backend/src/domain/services/rate-offer-generator.service.ts
Normal file
257
apps/backend/src/domain/services/rate-offer-generator.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
282
apps/backend/test-csv-offers-api.sh
Normal file
282
apps/backend/test-csv-offers-api.sh
Normal 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 "================================================"
|
||||
Loading…
Reference in New Issue
Block a user