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
|
* Get available companies
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -369,4 +369,26 @@ export class CsvRateResultDto {
|
|||||||
example: 95,
|
example: 95,
|
||||||
})
|
})
|
||||||
matchScore: number;
|
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(),
|
hasSurcharges: rate.hasSurcharges(),
|
||||||
surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null,
|
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],
|
validUntil: rate.validity.getEndDate().toISOString().split('T')[0],
|
||||||
source: result.source,
|
source: result.source,
|
||||||
matchScore: result.matchScore,
|
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 { CsvRate } from '../../entities/csv-rate.entity';
|
||||||
import { PortCode } from '../../value-objects/port-code.vo';
|
import { PortCode } from '../../value-objects/port-code.vo';
|
||||||
import { Volume } from '../../value-objects/volume.vo';
|
import { Volume } from '../../value-objects/volume.vo';
|
||||||
|
import { ServiceLevel } from '../../services/rate-offer-generator.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advanced Rate Search Filters
|
* Advanced Rate Search Filters
|
||||||
@ -35,6 +36,9 @@ export interface RateSearchFilters {
|
|||||||
|
|
||||||
// Date filters
|
// Date filters
|
||||||
departureDate?: Date; // Filter by validity for specific date
|
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
|
priceBreakdown: PriceBreakdown; // Detailed price calculation
|
||||||
source: 'CSV';
|
source: 'CSV';
|
||||||
matchScore: number; // 0-100, how well it matches filters
|
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(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
|
* Get available companies in CSV system
|
||||||
* @returns List of company names that have CSV rates
|
* @returns List of company names that have CSV rates
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
} from '@domain/ports/in/search-csv-rates.port';
|
} from '@domain/ports/in/search-csv-rates.port';
|
||||||
import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port';
|
import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port';
|
||||||
import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service';
|
import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service';
|
||||||
|
import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Config Metadata Interface (to avoid circular dependency)
|
* Config Metadata Interface (to avoid circular dependency)
|
||||||
@ -42,12 +43,14 @@ export interface CsvRateConfigRepositoryPort {
|
|||||||
*/
|
*/
|
||||||
export class CsvRateSearchService implements SearchCsvRatesPort {
|
export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||||
private readonly priceCalculator: CsvRatePriceCalculatorService;
|
private readonly priceCalculator: CsvRatePriceCalculatorService;
|
||||||
|
private readonly offerGenerator: RateOfferGeneratorService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly csvRateLoader: CsvRateLoaderPort,
|
private readonly csvRateLoader: CsvRateLoaderPort,
|
||||||
private readonly configRepository?: CsvRateConfigRepositoryPort
|
private readonly configRepository?: CsvRateConfigRepositoryPort
|
||||||
) {
|
) {
|
||||||
this.priceCalculator = new CsvRatePriceCalculatorService();
|
this.priceCalculator = new CsvRatePriceCalculatorService();
|
||||||
|
this.offerGenerator = new RateOfferGeneratorService();
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
|
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[]> {
|
async getAvailableCompanies(): Promise<string[]> {
|
||||||
const allRates = await this.loadAllRates();
|
const allRates = await this.loadAllRates();
|
||||||
const companies = new Set(allRates.map(rate => rate.companyName));
|
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