feature csv rates

This commit is contained in:
David 2025-10-29 21:18:53 +01:00
parent 634b9adc4a
commit cb0d44bb34
10 changed files with 510 additions and 28 deletions

View File

@ -27,7 +27,11 @@
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"SELECT id, email, role FROM users LIMIT 5;\")", "Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"SELECT id, email, role FROM users LIMIT 5;\")",
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"UPDATE users SET role = ''ADMIN'' WHERE email = ''test4@xpeditis.com''; SELECT id, email, role FROM users WHERE email = ''test4@xpeditis.com'';\")", "Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"UPDATE users SET role = ''ADMIN'' WHERE email = ''test4@xpeditis.com''; SELECT id, email, role FROM users WHERE email = ''test4@xpeditis.com'';\")",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MTU5MDg0OSwiZXhwIjoxNzYxNTkxNzQ5fQ.CPFhvgASXuklZ81FiuX_XwYZfh8xKG4tNG70JQ4Dv8M\")", "Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MTU5MDg0OSwiZXhwIjoxNzYxNTkxNzQ5fQ.CPFhvgASXuklZ81FiuX_XwYZfh8xKG4tNG70JQ4Dv8M\")",
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"UPDATE users SET role = ''ADMIN'' WHERE email = ''dharnaud77@hotmail.fr''; SELECT id, email, role FROM users WHERE email = ''dharnaud77@hotmail.fr'';\")" "Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"UPDATE users SET role = ''ADMIN'' WHERE email = ''dharnaud77@hotmail.fr''; SELECT id, email, role FROM users WHERE email = ''dharnaud77@hotmail.fr'';\")",
"Bash(npm run format:*)",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MTU5Njk0MywiZXhwIjoxNzYxNTk3ODQzfQ.cwvInoHK_vR24aRRlkJGBv_VBkgyfpCwpXyrAhulQYI\")",
"Read(//Users/david/Downloads/drive-download-20251023T120052Z-1-001/**)",
"Bash(bash:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@ -29,6 +29,7 @@ import { RolesGuard } from '../../guards/roles.guard';
import { Roles } from '../../decorators/roles.decorator'; import { Roles } from '../../decorators/roles.decorator';
import { CurrentUser, UserPayload } from '../../decorators/current-user.decorator'; import { CurrentUser, UserPayload } from '../../decorators/current-user.decorator';
import { CsvRateLoaderAdapter } from '@infrastructure/carriers/csv-loader/csv-rate-loader.adapter'; import { CsvRateLoaderAdapter } from '@infrastructure/carriers/csv-loader/csv-rate-loader.adapter';
import { CsvConverterService } from '@infrastructure/carriers/csv-loader/csv-converter.service';
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository'; import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
import { import {
CsvRateUploadDto, CsvRateUploadDto,
@ -54,6 +55,7 @@ export class CsvRatesAdminController {
constructor( constructor(
private readonly csvLoader: CsvRateLoaderAdapter, private readonly csvLoader: CsvRateLoaderAdapter,
private readonly csvConverter: CsvConverterService,
private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository, private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository,
private readonly csvRateMapper: CsvRateMapper private readonly csvRateMapper: CsvRateMapper
) {} ) {}
@ -139,8 +141,18 @@ export class CsvRatesAdminController {
} }
try { try {
// Validate CSV file structure // Auto-convert CSV if needed (FOB FRET → Standard format)
const validation = await this.csvLoader.validateCsvFile(file.filename); const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName);
const filePathToValidate = conversionResult.convertedPath;
if (conversionResult.wasConverted) {
this.logger.log(
`Converted ${conversionResult.rowsConverted} rows from FOB FRET format to standard format`
);
}
// Validate CSV file structure using the converted path
const validation = await this.csvLoader.validateCsvFile(filePathToValidate);
if (!validation.valid) { if (!validation.valid) {
this.logger.error( this.logger.error(
@ -152,8 +164,8 @@ export class CsvRatesAdminController {
}); });
} }
// Load rates to verify parsing // Load rates to verify parsing using the converted path
const rates = await this.csvLoader.loadRatesFromCsv(file.filename); const rates = await this.csvLoader.loadRatesFromCsv(filePathToValidate);
const ratesCount = rates.length; const ratesCount = rates.length;
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`); this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);

View File

@ -164,6 +164,15 @@ export class RatesController {
palletCount: dto.palletCount ?? 0, palletCount: dto.palletCount ?? 0,
containerType: dto.containerType, containerType: dto.containerType,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), 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 // Execute CSV rate search

View File

@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsNumber, Min, IsOptional, ValidateNested } from 'class-validator'; import { IsNotEmpty, IsString, IsNumber, Min, IsOptional, ValidateNested, IsBoolean } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { RateSearchFiltersDto } from './rate-search-filters.dto'; import { RateSearchFiltersDto } from './rate-search-filters.dto';
@ -75,6 +75,70 @@ export class CsvRateSearchDto {
@ValidateNested() @ValidateNested()
@Type(() => RateSearchFiltersDto) @Type(() => RateSearchFiltersDto)
filters?: RateSearchFiltersDto; filters?: RateSearchFiltersDto;
// Service requirements for detailed price calculation
@ApiPropertyOptional({
description: 'Cargo contains dangerous goods (DG)',
example: true,
default: false,
})
@IsOptional()
@IsBoolean()
hasDangerousGoods?: boolean;
@ApiPropertyOptional({
description: 'Requires special handling',
example: true,
default: false,
})
@IsOptional()
@IsBoolean()
requiresSpecialHandling?: boolean;
@ApiPropertyOptional({
description: 'Requires tailgate lift',
example: false,
default: false,
})
@IsOptional()
@IsBoolean()
requiresTailgate?: boolean;
@ApiPropertyOptional({
description: 'Requires securing straps',
example: true,
default: false,
})
@IsOptional()
@IsBoolean()
requiresStraps?: boolean;
@ApiPropertyOptional({
description: 'Requires thermal protection cover',
example: false,
default: false,
})
@IsOptional()
@IsBoolean()
requiresThermalCover?: boolean;
@ApiPropertyOptional({
description: 'Contains regulated products requiring special documentation',
example: false,
default: false,
})
@IsOptional()
@IsBoolean()
hasRegulatedProducts?: boolean;
@ApiPropertyOptional({
description: 'Requires delivery appointment',
example: true,
default: false,
})
@IsOptional()
@IsBoolean()
requiresAppointment?: boolean;
} }
/** /**
@ -115,6 +179,90 @@ export class CsvRateSearchResponseDto {
appliedFilters: RateSearchFiltersDto; appliedFilters: RateSearchFiltersDto;
} }
/**
* Surcharge Item DTO
*/
export class SurchargeItemDto {
@ApiProperty({
description: 'Surcharge code',
example: 'DG_FEE',
})
code: string;
@ApiProperty({
description: 'Surcharge description',
example: 'Dangerous goods fee',
})
description: string;
@ApiProperty({
description: 'Surcharge amount in currency',
example: 65.0,
})
amount: number;
@ApiProperty({
description: 'Type of surcharge calculation',
enum: ['FIXED', 'PER_UNIT', 'PERCENTAGE'],
example: 'FIXED',
})
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
}
/**
* Price Breakdown DTO
*/
export class PriceBreakdownDto {
@ApiProperty({
description: 'Base price before any charges',
example: 0,
})
basePrice: number;
@ApiProperty({
description: 'Charge based on volume (CBM)',
example: 150.0,
})
volumeCharge: number;
@ApiProperty({
description: 'Charge based on weight (KG)',
example: 25.0,
})
weightCharge: number;
@ApiProperty({
description: 'Charge for pallets',
example: 125.0,
})
palletCharge: number;
@ApiProperty({
description: 'List of all surcharges',
type: [SurchargeItemDto],
})
surcharges: SurchargeItemDto[];
@ApiProperty({
description: 'Total of all surcharges',
example: 242.0,
})
totalSurcharges: number;
@ApiProperty({
description: 'Total price including all charges',
example: 542.0,
})
totalPrice: number;
@ApiProperty({
description: 'Currency of the pricing',
enum: ['USD', 'EUR'],
example: 'USD',
})
currency: string;
}
/** /**
* Single CSV Rate Result DTO * Single CSV Rate Result DTO
*/ */
@ -162,6 +310,12 @@ export class CsvRateResultDto {
}) })
primaryCurrency: string; primaryCurrency: string;
@ApiProperty({
description: 'Detailed price breakdown with all charges',
type: PriceBreakdownDto,
})
priceBreakdown: PriceBreakdownDto;
@ApiProperty({ @ApiProperty({
description: 'Whether this rate has separate surcharges', description: 'Whether this rate has separate surcharges',
example: true, example: true,

View File

@ -60,6 +60,21 @@ export class CsvRateMapper {
priceUSD: result.calculatedPrice.usd, priceUSD: result.calculatedPrice.usd,
priceEUR: result.calculatedPrice.eur, priceEUR: result.calculatedPrice.eur,
primaryCurrency: result.calculatedPrice.primaryCurrency, primaryCurrency: result.calculatedPrice.primaryCurrency,
priceBreakdown: {
basePrice: result.priceBreakdown.basePrice,
volumeCharge: result.priceBreakdown.volumeCharge,
weightCharge: result.priceBreakdown.weightCharge,
palletCharge: result.priceBreakdown.palletCharge,
surcharges: result.priceBreakdown.surcharges.map(s => ({
code: s.code,
description: s.description,
amount: s.amount,
type: s.type,
})),
totalSurcharges: result.priceBreakdown.totalSurcharges,
totalPrice: result.priceBreakdown.totalPrice,
currency: result.priceBreakdown.currency,
},
hasSurcharges: rate.hasSurcharges(), hasSurcharges: rate.hasSurcharges(),
surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null, surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null,
transitDays: rate.transitDays, transitDays: rate.transitDays,

View File

@ -50,6 +50,39 @@ export interface CsvRateSearchInput {
palletCount?: number; // Number of pallets (0 if none) palletCount?: number; // Number of pallets (0 if none)
containerType?: string; // Optional container type filter containerType?: string; // Optional container type filter
filters?: RateSearchFilters; // Advanced filters filters?: RateSearchFilters; // Advanced filters
// Service requirements for price calculation
hasDangerousGoods?: boolean;
requiresSpecialHandling?: boolean;
requiresTailgate?: boolean;
requiresStraps?: boolean;
requiresThermalCover?: boolean;
hasRegulatedProducts?: boolean;
requiresAppointment?: boolean;
}
/**
* Surcharge Item - Individual fee or charge
*/
export interface SurchargeItem {
code: string;
description: string;
amount: number;
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
}
/**
* Price Breakdown - Detailed pricing calculation
*/
export interface PriceBreakdown {
basePrice: number;
volumeCharge: number;
weightCharge: number;
palletCharge: number;
surcharges: SurchargeItem[];
totalSurcharges: number;
totalPrice: number;
currency: string;
} }
/** /**
@ -64,6 +97,7 @@ export interface CsvRateSearchResult {
eur: number; eur: number;
primaryCurrency: string; primaryCurrency: string;
}; };
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
} }

View File

@ -0,0 +1,231 @@
import { CsvRate } from '../entities/csv-rate.entity';
export interface PriceCalculationParams {
volumeCBM: number;
weightKG: number;
palletCount: number;
hasDangerousGoods: boolean;
requiresSpecialHandling: boolean;
requiresTailgate: boolean;
requiresStraps: boolean;
requiresThermalCover: boolean;
hasRegulatedProducts: boolean;
requiresAppointment: boolean;
}
export interface PriceBreakdown {
basePrice: number;
volumeCharge: number;
weightCharge: number;
palletCharge: number;
surcharges: SurchargeItem[];
totalSurcharges: number;
totalPrice: number;
currency: string;
}
export interface SurchargeItem {
code: string;
description: string;
amount: number;
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
}
/**
* Service de calcul de prix pour les tarifs CSV
* Calcule le prix total basé sur le volume, poids, palettes et services additionnels
*/
export class CsvRatePriceCalculatorService {
/**
* Calcule le prix total pour un tarif CSV donné
*/
calculatePrice(
rate: CsvRate,
params: PriceCalculationParams,
): PriceBreakdown {
// 1. Prix de base
const basePrice = rate.pricing.basePriceUSD.getAmount();
// 2. Frais au volume (USD par CBM)
const volumeCharge = rate.pricing.pricePerCBM * params.volumeCBM;
// 3. Frais au poids (USD par KG)
const weightCharge = rate.pricing.pricePerKG * params.weightKG;
// 4. Frais de palettes (25 USD par palette)
const palletCharge = params.palletCount * 25;
// 5. Surcharges standard du CSV
const standardSurcharges = this.parseStandardSurcharges(
rate.getSurchargeDetails(),
params,
);
// 6. Surcharges additionnelles basées sur les services
const additionalSurcharges = this.calculateAdditionalSurcharges(params);
// 7. Total des surcharges
const allSurcharges = [...standardSurcharges, ...additionalSurcharges];
const totalSurcharges = allSurcharges.reduce(
(sum, s) => sum + s.amount,
0,
);
// 8. Prix total
const totalPrice =
basePrice + volumeCharge + weightCharge + palletCharge + totalSurcharges;
return {
basePrice,
volumeCharge,
weightCharge,
palletCharge,
surcharges: allSurcharges,
totalSurcharges,
totalPrice: Math.round(totalPrice * 100) / 100, // Arrondi à 2 décimales
currency: rate.currency || 'USD',
};
}
/**
* Parse les surcharges standard du format CSV
* Format: "DOC:10 | ISPS:7 | HANDLING:20 W | DG_FEE:65"
*/
private parseStandardSurcharges(
surchargeDetails: string | null,
params: PriceCalculationParams,
): SurchargeItem[] {
if (!surchargeDetails) {
return [];
}
const surcharges: SurchargeItem[] = [];
const items = surchargeDetails.split('|').map((s) => s.trim());
for (const item of items) {
const match = item.match(/^([A-Z_]+):(\d+(?:\.\d+)?)\s*([WP%]?)$/);
if (!match) continue;
const [, code, amountStr, type] = match;
let amount = parseFloat(amountStr);
let surchargeType: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE' = 'FIXED';
// Calcul selon le type
if (type === 'W') {
// Par poids (W = Weight)
amount = amount * params.weightKG;
surchargeType = 'PER_UNIT';
} else if (type === 'P') {
// Par palette
amount = amount * params.palletCount;
surchargeType = 'PER_UNIT';
} else if (type === '%') {
// Pourcentage (sera appliqué sur le total)
surchargeType = 'PERCENTAGE';
}
// Certaines surcharges ne s'appliquent que si certaines conditions sont remplies
if (code === 'DG_FEE' && !params.hasDangerousGoods) {
continue; // Skip DG fee si pas de marchandises dangereuses
}
surcharges.push({
code,
description: this.getSurchargeDescription(code),
amount: Math.round(amount * 100) / 100,
type: surchargeType,
});
}
return surcharges;
}
/**
* Calcule les surcharges additionnelles basées sur les services demandés
*/
private calculateAdditionalSurcharges(
params: PriceCalculationParams,
): SurchargeItem[] {
const surcharges: SurchargeItem[] = [];
if (params.requiresSpecialHandling) {
surcharges.push({
code: 'SPECIAL_HANDLING',
description: 'Manutention particulière',
amount: 75,
type: 'FIXED',
});
}
if (params.requiresTailgate) {
surcharges.push({
code: 'TAILGATE',
description: 'Hayon élévateur',
amount: 50,
type: 'FIXED',
});
}
if (params.requiresStraps) {
surcharges.push({
code: 'STRAPS',
description: 'Sangles de sécurité',
amount: 30,
type: 'FIXED',
});
}
if (params.requiresThermalCover) {
surcharges.push({
code: 'THERMAL_COVER',
description: 'Couverture thermique',
amount: 100,
type: 'FIXED',
});
}
if (params.hasRegulatedProducts) {
surcharges.push({
code: 'REGULATED_PRODUCTS',
description: 'Produits réglementés',
amount: 80,
type: 'FIXED',
});
}
if (params.requiresAppointment) {
surcharges.push({
code: 'APPOINTMENT',
description: 'Livraison sur rendez-vous',
amount: 40,
type: 'FIXED',
});
}
return surcharges;
}
/**
* Retourne la description d'un code de surcharge standard
*/
private getSurchargeDescription(code: string): string {
const descriptions: Record<string, string> = {
DOC: 'Documentation fee',
ISPS: 'ISPS Security',
HANDLING: 'Handling charges',
SOLAS: 'SOLAS VGM',
CUSTOMS: 'Customs clearance',
AMS_ACI: 'AMS/ACI filing',
DG_FEE: 'Dangerous goods fee',
BAF: 'Bunker Adjustment Factor',
CAF: 'Currency Adjustment Factor',
THC: 'Terminal Handling Charges',
BL_FEE: 'Bill of Lading fee',
TELEX_RELEASE: 'Telex release',
ORIGIN_CHARGES: 'Origin charges',
DEST_CHARGES: 'Destination charges',
};
return descriptions[code] || code.replace(/_/g, ' ');
}
}

View File

@ -11,6 +11,7 @@ import {
RateSearchFilters, RateSearchFilters,
} from '../ports/in/search-csv-rates.port'; } from '../ports/in/search-csv-rates.port';
import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port'; import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port';
import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service';
/** /**
* CSV Rate Search Service * CSV Rate Search Service
@ -21,7 +22,11 @@ import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port';
* Pure domain logic - no framework dependencies. * Pure domain logic - no framework dependencies.
*/ */
export class CsvRateSearchService implements SearchCsvRatesPort { export class CsvRateSearchService implements SearchCsvRatesPort {
constructor(private readonly csvRateLoader: CsvRateLoaderPort) {} private readonly priceCalculator: CsvRatePriceCalculatorService;
constructor(private readonly csvRateLoader: CsvRateLoaderPort) {
this.priceCalculator = new CsvRatePriceCalculatorService();
}
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> { async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
const searchStartTime = new Date(); const searchStartTime = new Date();
@ -53,29 +58,35 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
// Calculate prices and create results // Calculate prices and create results
const results: CsvRateSearchResult[] = matchingRates.map(rate => { const results: CsvRateSearchResult[] = matchingRates.map(rate => {
const priceUSD = rate.getPriceInCurrency(volume, 'USD'); // Calculate detailed price breakdown
const priceEUR = rate.getPriceInCurrency(volume, 'EUR'); const priceBreakdown = this.priceCalculator.calculatePrice(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,
});
return { return {
rate, rate,
calculatedPrice: { calculatedPrice: {
usd: priceUSD.getAmount(), usd: priceBreakdown.totalPrice,
eur: priceEUR.getAmount(), eur: priceBreakdown.totalPrice, // TODO: Add currency conversion
primaryCurrency: rate.currency, primaryCurrency: priceBreakdown.currency,
}, },
priceBreakdown,
source: 'CSV' as const, source: 'CSV' as const,
matchScore: this.calculateMatchScore(rate, input), matchScore: this.calculateMatchScore(rate, input),
}; };
}); });
// Sort by price (ascending) in primary currency // Sort by total price (ascending)
results.sort((a, b) => { results.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice);
const priceA =
a.calculatedPrice.primaryCurrency === 'USD' ? a.calculatedPrice.usd : a.calculatedPrice.eur;
const priceB =
b.calculatedPrice.primaryCurrency === 'USD' ? b.calculatedPrice.usd : b.calculatedPrice.eur;
return priceA - priceB;
});
return { return {
results, results,

View File

@ -264,14 +264,18 @@ export class CsvConverterService {
outputLines.push(values.join(',')); outputLines.push(values.join(','));
}); });
// Écrire le fichier converti // Écrire le fichier converti (garder le chemin absolu)
const outputPath = inputPath.replace('.csv', '-converted.csv'); const outputPath = inputPath.replace('.csv', '-converted.csv');
await fs.writeFile(outputPath, outputLines.join('\n'), 'utf-8'); const absoluteOutputPath = path.isAbsolute(outputPath)
? outputPath
: path.resolve(process.cwd(), outputPath);
this.logger.log(`Conversion completed: ${outputPath} (${convertedRows.length} rows)`); await fs.writeFile(absoluteOutputPath, outputLines.join('\n'), 'utf-8');
this.logger.log(`Conversion completed: ${absoluteOutputPath} (${convertedRows.length} rows)`);
return { return {
outputPath, outputPath: absoluteOutputPath,
rowsConverted: convertedRows.length, rowsConverted: convertedRows.length,
}; };
} catch (error) { } catch (error) {

View File

@ -6,6 +6,7 @@ import { CsvRateSearchService } from '@domain/services/csv-rate-search.service';
// Infrastructure Adapters // Infrastructure Adapters
import { CsvRateLoaderAdapter } from './csv-rate-loader.adapter'; import { CsvRateLoaderAdapter } from './csv-rate-loader.adapter';
import { CsvConverterService } from './csv-converter.service';
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository'; import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
// Application Layer // Application Layer
@ -33,13 +34,20 @@ import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/enti
TypeOrmModule.forFeature([CsvRateConfigOrmEntity]), TypeOrmModule.forFeature([CsvRateConfigOrmEntity]),
], ],
providers: [ providers: [
// Domain Services // Infrastructure Adapters (must be before services that depend on them)
CsvRateSearchService,
// Infrastructure Adapters
CsvRateLoaderAdapter, CsvRateLoaderAdapter,
CsvConverterService,
TypeOrmCsvRateConfigRepository, TypeOrmCsvRateConfigRepository,
// Domain Services (with factory to inject dependencies)
{
provide: CsvRateSearchService,
useFactory: (csvRateLoader: CsvRateLoaderAdapter) => {
return new CsvRateSearchService(csvRateLoader);
},
inject: [CsvRateLoaderAdapter],
},
// Application Mappers // Application Mappers
CsvRateMapper, CsvRateMapper,
], ],