Compare commits

..

No commits in common. "cb0d44bb348de8e497a2baec1b71d228104a977e" and "d809feecef340d24f8015eca7652c0ad8d66f6f2" have entirely different histories.

11 changed files with 25 additions and 1088 deletions

View File

@ -27,11 +27,7 @@
"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(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(npm run format:*)",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MTU5Njk0MywiZXhwIjoxNzYxNTk3ODQzfQ.cwvInoHK_vR24aRRlkJGBv_VBkgyfpCwpXyrAhulQYI\")",
"Read(//Users/david/Downloads/drive-download-20251023T120052Z-1-001/**)",
"Bash(bash:*)"
"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'';\")"
],
"deny": [],
"ask": []

View File

@ -29,7 +29,6 @@ import { RolesGuard } from '../../guards/roles.guard';
import { Roles } from '../../decorators/roles.decorator';
import { CurrentUser, UserPayload } from '../../decorators/current-user.decorator';
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 {
CsvRateUploadDto,
@ -55,7 +54,6 @@ export class CsvRatesAdminController {
constructor(
private readonly csvLoader: CsvRateLoaderAdapter,
private readonly csvConverter: CsvConverterService,
private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository,
private readonly csvRateMapper: CsvRateMapper
) {}
@ -141,18 +139,8 @@ export class CsvRatesAdminController {
}
try {
// Auto-convert CSV if needed (FOB FRET → Standard format)
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);
// Validate CSV file structure
const validation = await this.csvLoader.validateCsvFile(file.filename);
if (!validation.valid) {
this.logger.error(
@ -164,8 +152,8 @@ export class CsvRatesAdminController {
});
}
// Load rates to verify parsing using the converted path
const rates = await this.csvLoader.loadRatesFromCsv(filePathToValidate);
// Load rates to verify parsing
const rates = await this.csvLoader.loadRatesFromCsv(file.filename);
const ratesCount = rates.length;
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);

View File

@ -164,15 +164,6 @@ export class RatesController {
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

View File

@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString, IsNumber, Min, IsOptional, ValidateNested, IsBoolean } from 'class-validator';
import { IsNotEmpty, IsString, IsNumber, Min, IsOptional, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { RateSearchFiltersDto } from './rate-search-filters.dto';
@ -75,70 +75,6 @@ export class CsvRateSearchDto {
@ValidateNested()
@Type(() => 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;
}
/**
@ -179,90 +115,6 @@ export class CsvRateSearchResponseDto {
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
*/
@ -310,12 +162,6 @@ export class CsvRateResultDto {
})
primaryCurrency: string;
@ApiProperty({
description: 'Detailed price breakdown with all charges',
type: PriceBreakdownDto,
})
priceBreakdown: PriceBreakdownDto;
@ApiProperty({
description: 'Whether this rate has separate surcharges',
example: true,

View File

@ -60,21 +60,6 @@ export class CsvRateMapper {
priceUSD: result.calculatedPrice.usd,
priceEUR: result.calculatedPrice.eur,
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(),
surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null,
transitDays: rate.transitDays,

View File

@ -50,39 +50,6 @@ export interface CsvRateSearchInput {
palletCount?: number; // Number of pallets (0 if none)
containerType?: string; // Optional container type filter
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;
}
/**
@ -97,7 +64,6 @@ export interface CsvRateSearchResult {
eur: number;
primaryCurrency: string;
};
priceBreakdown: PriceBreakdown; // Detailed price calculation
source: 'CSV';
matchScore: number; // 0-100, how well it matches filters
}

View File

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

View File

@ -1,318 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs/promises';
import * as path from 'path';
/**
* CSV Converter Service
*
* Détecte automatiquement le format du CSV et convertit au format attendu
* Supporte:
* - Format standard Xpeditis
* - Format "Frais FOB FRET"
*/
@Injectable()
export class CsvConverterService {
private readonly logger = new Logger(CsvConverterService.name);
// Headers du format standard attendu
private readonly STANDARD_HEADERS = [
'companyName',
'origin',
'destination',
'containerType',
'minVolumeCBM',
'maxVolumeCBM',
'minWeightKG',
'maxWeightKG',
'palletCount',
'pricePerCBM',
'pricePerKG',
'basePriceUSD',
'basePriceEUR',
'currency',
'hasSurcharges',
'surchargeBAF',
'surchargeCAF',
'surchargeDetails',
'transitDays',
'validFrom',
'validUntil',
];
// Headers du format "Frais FOB FRET"
private readonly FOB_FRET_HEADERS = [
'Origine UN code',
'Destination UN code',
'Devise FRET',
'Taux de FRET (UP)',
'Minimum FRET (LS)',
'Transit time',
];
/**
* Parse une ligne CSV en gérant les champs entre guillemets
*/
private parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
/**
* Détecte le format du CSV
*/
async detectFormat(filePath: string): Promise<'STANDARD' | 'FOB_FRET' | 'UNKNOWN'> {
try {
const content = await fs.readFile(filePath, 'utf-8');
const lines = content.split('\n').filter(line => line.trim());
if (lines.length === 0) {
return 'UNKNOWN';
}
// Vérifier les 2 premières lignes (parfois la vraie ligne d'en-tête est la ligne 2)
for (let i = 0; i < Math.min(2, lines.length); i++) {
const headers = this.parseCSVLine(lines[i]);
// Vérifier format standard
const hasStandardHeaders = this.STANDARD_HEADERS.some(h => headers.includes(h));
if (hasStandardHeaders) {
return 'STANDARD';
}
// Vérifier format FOB FRET
const hasFobFretHeaders = this.FOB_FRET_HEADERS.some(h => headers.includes(h));
if (hasFobFretHeaders) {
return 'FOB_FRET';
}
}
return 'UNKNOWN';
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Error detecting CSV format: ${errorMessage}`);
return 'UNKNOWN';
}
}
/**
* Calcule les surcharges à partir des colonnes FOB
*/
private calculateSurcharges(row: Record<string, string>): string {
const surcharges: string[] = [];
const surchargeFields = [
{ key: 'Documentation (LS et Minimum)', prefix: 'DOC' },
{ key: 'ISPS (LS et Minimum)', prefix: 'ISPS' },
{ key: 'Manutention', prefix: 'HANDLING' },
{ key: 'Solas (LS et Minimum)', prefix: 'SOLAS' },
{ key: 'Douane (LS et Minimum)', prefix: 'CUSTOMS' },
{ key: 'AMS/ACI (LS et Minimum)', prefix: 'AMS_ACI' },
{ key: 'ISF5 (LS et Minimum)', prefix: 'ISF5' },
{ key: 'Frais admin de dangereux (LS et Minimum)', prefix: 'DG_FEE' },
];
surchargeFields.forEach(({ key, prefix }) => {
if (row[key]) {
const unit = key === 'Manutention' ? row['Unité de manutention (UP;Tonne)'] || 'UP' : '';
surcharges.push(`${prefix}:${row[key]}${unit ? ' ' + unit : ''}`);
}
});
return surcharges.join(' | ');
}
/**
* Convertit une ligne FOB FRET vers le format standard
*/
private convertFobFretRow(row: Record<string, string>, companyName: string): Record<string, any> {
const currency = row['Devise FRET'] || 'USD';
const freightRate = parseFloat(row['Taux de FRET (UP)']) || 0;
const minFreight = parseFloat(row['Minimum FRET (LS)']) || 0;
const transitDays = parseInt(row['Transit time']) || 0;
// Calcul des surcharges
const surchargeDetails = this.calculateSurcharges(row);
const hasSurcharges = surchargeDetails.length > 0;
// Dates de validité (90 jours par défaut)
const validFrom = new Date().toISOString().split('T')[0];
const validUntil = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
// Volumes et poids standards pour LCL
const minVolumeCBM = 1;
const maxVolumeCBM = 20;
const minWeightKG = 100;
const maxWeightKG = 20000;
// Prix par CBM
const pricePerCBM = freightRate > 0 ? freightRate : minFreight;
// Prix par KG (estimation: prix CBM / 200 kg/m³)
const pricePerKG = pricePerCBM > 0 ? (pricePerCBM / 200).toFixed(2) : '0';
return {
companyName,
origin: row['Origine UN code'] || '',
destination: row['Destination UN code'] || '',
containerType: 'LCL',
minVolumeCBM,
maxVolumeCBM,
minWeightKG,
maxWeightKG,
palletCount: 0,
pricePerCBM,
pricePerKG,
basePriceUSD: currency === 'USD' ? pricePerCBM : 0,
basePriceEUR: currency === 'EUR' ? pricePerCBM : 0,
currency,
hasSurcharges,
surchargeBAF: '',
surchargeCAF: '',
surchargeDetails,
transitDays,
validFrom,
validUntil,
};
}
/**
* Convertit un CSV FOB FRET vers le format standard
*/
async convertFobFretToStandard(
inputPath: string,
companyName: string
): Promise<{ outputPath: string; rowsConverted: number }> {
this.logger.log(`Converting FOB FRET CSV: ${inputPath}`);
try {
// Lire le fichier
const content = await fs.readFile(inputPath, 'utf-8');
const lines = content.split('\n').filter(line => line.trim());
if (lines.length < 2) {
throw new Error('CSV file is empty or has no data rows');
}
// Trouver la ligne d'en-tête réelle (chercher celle avec "Devise FRET")
let headerLineIndex = 0;
for (let i = 0; i < Math.min(2, lines.length); i++) {
const headers = this.parseCSVLine(lines[i]);
if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) {
headerLineIndex = i;
break;
}
}
// Parse headers
const headers = this.parseCSVLine(lines[headerLineIndex]);
this.logger.log(`Found FOB FRET headers at line ${headerLineIndex + 1}`);
// Parse data rows (commencer après la ligne d'en-tête)
const dataRows: Record<string, string>[] = [];
for (let i = headerLineIndex + 1; i < lines.length; i++) {
const values = this.parseCSVLine(lines[i]);
const row: Record<string, string> = {};
headers.forEach((header, index) => {
row[header] = values[index] || '';
});
// Vérifier que la ligne a des données valides
if (row['Origine UN code'] && row['Destination UN code']) {
dataRows.push(row);
}
}
this.logger.log(`Found ${dataRows.length} valid data rows`);
// Convertir les lignes
const convertedRows = dataRows.map(row => this.convertFobFretRow(row, companyName));
// Générer le CSV de sortie
const outputLines: string[] = [this.STANDARD_HEADERS.join(',')];
convertedRows.forEach(row => {
const values = this.STANDARD_HEADERS.map(header => {
const value = row[header];
// Échapper les virgules et quotes
if (
typeof value === 'string' &&
(value.includes(',') || value.includes('"') || value.includes('\n'))
) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
});
outputLines.push(values.join(','));
});
// Écrire le fichier converti (garder le chemin absolu)
const outputPath = inputPath.replace('.csv', '-converted.csv');
const absoluteOutputPath = path.isAbsolute(outputPath)
? outputPath
: path.resolve(process.cwd(), outputPath);
await fs.writeFile(absoluteOutputPath, outputLines.join('\n'), 'utf-8');
this.logger.log(`Conversion completed: ${absoluteOutputPath} (${convertedRows.length} rows)`);
return {
outputPath: absoluteOutputPath,
rowsConverted: convertedRows.length,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Error converting CSV: ${errorMessage}`, errorStack);
throw new Error(`CSV conversion failed: ${errorMessage}`);
}
}
/**
* Convertit automatiquement un CSV si nécessaire
*/
async autoConvert(
inputPath: string,
companyName: string
): Promise<{ convertedPath: string; wasConverted: boolean; rowsConverted?: number }> {
const format = await this.detectFormat(inputPath);
this.logger.log(`Detected CSV format: ${format}`);
if (format === 'STANDARD') {
return {
convertedPath: inputPath,
wasConverted: false,
};
}
if (format === 'FOB_FRET') {
const result = await this.convertFobFretToStandard(inputPath, companyName);
return {
convertedPath: result.outputPath,
wasConverted: true,
rowsConverted: result.rowsConverted,
};
}
throw new Error(`Unknown CSV format. Please provide a valid CSV file.`);
}
}

View File

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

View File

@ -1,267 +0,0 @@
#!/usr/bin/env node
/**
* Script de conversion CSV pour les tarifs maritimes
* Convertit le format "Frais FOB FRET" vers le format d'import Xpeditis
*/
const fs = require('fs');
const path = require('path');
// Configuration
const INPUT_FILE = process.argv[2] || path.join(__dirname, '../Frais FOB FRET.csv');
const OUTPUT_FILE = process.argv[3] || path.join(__dirname, '../rates-import-ready.csv');
const COMPANY_NAME = process.argv[4] || 'Consolidation Maritime';
// Headers attendus par l'API
const OUTPUT_HEADERS = [
'companyName',
'origin',
'destination',
'containerType',
'minVolumeCBM',
'maxVolumeCBM',
'minWeightKG',
'maxWeightKG',
'palletCount',
'pricePerCBM',
'pricePerKG',
'basePriceUSD',
'basePriceEUR',
'currency',
'hasSurcharges',
'surchargeBAF',
'surchargeCAF',
'surchargeDetails',
'transitDays',
'validFrom',
'validUntil'
];
/**
* Parse CSV line handling quoted fields
*/
function parseCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
/**
* Calcule les frais supplémentaires à partir des colonnes FOB
*/
function calculateSurcharges(row) {
const surcharges = [];
// Documentation (LS et Minimum)
if (row['Documentation (LS et Minimum)']) {
surcharges.push(`DOC:${row['Documentation (LS et Minimum)']}`);
}
// ISPS (LS et Minimum)
if (row['ISPS (LS et Minimum)']) {
surcharges.push(`ISPS:${row['ISPS (LS et Minimum)']}`);
}
// Manutention
if (row['Manutention']) {
surcharges.push(`HANDLING:${row['Manutention']} ${row['Unité de manutention (UP;Tonne)'] || 'UP'}`);
}
// SOLAS
if (row['Solas (LS et Minimum)']) {
surcharges.push(`SOLAS:${row['Solas (LS et Minimum)']}`);
}
// Douane
if (row['Douane (LS et Minimum)']) {
surcharges.push(`CUSTOMS:${row['Douane (LS et Minimum)']}`);
}
// AMS/ACI
if (row['AMS/ACI (LS et Minimum)']) {
surcharges.push(`AMS_ACI:${row['AMS/ACI (LS et Minimum)']}`);
}
// ISF5
if (row['ISF5 (LS et Minimum)']) {
surcharges.push(`ISF5:${row['ISF5 (LS et Minimum)']}`);
}
// Frais admin dangereux
if (row['Frais admin de dangereux (LS et Minimum)']) {
surcharges.push(`DG_FEE:${row['Frais admin de dangereux (LS et Minimum)']}`);
}
return surcharges.join(' | ');
}
/**
* Conversion d'une ligne du CSV source vers le format de destination
*/
function convertRow(sourceRow) {
const currency = sourceRow['Devise FRET'] || 'USD';
const freightRate = parseFloat(sourceRow['Taux de FRET (UP)']) || 0;
const minFreight = parseFloat(sourceRow['Minimum FRET (LS)']) || 0;
const transitDays = parseInt(sourceRow['Transit time']) || 0;
// Calcul des surcharges
const surchargeDetails = calculateSurcharges(sourceRow);
const hasSurcharges = surchargeDetails.length > 0;
// Dates de validité (par défaut: 90 jours à partir d'aujourd'hui)
const validFrom = new Date().toISOString().split('T')[0];
const validUntil = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
// Estimation volume min/max (1-20 CBM pour LCL standard)
const minVolumeCBM = 1;
const maxVolumeCBM = 20;
// Estimation poids min/max (100-20000 KG)
const minWeightKG = 100;
const maxWeightKG = 20000;
// Prix par CBM (basé sur le taux de fret)
const pricePerCBM = freightRate > 0 ? freightRate : minFreight;
// Prix par KG (estimation: prix CBM / 200 kg/m³)
const pricePerKG = pricePerCBM > 0 ? (pricePerCBM / 200).toFixed(2) : 0;
return {
companyName: COMPANY_NAME,
origin: sourceRow['Origine UN code'] || '',
destination: sourceRow['Destination UN code'] || '',
containerType: 'LCL',
minVolumeCBM: minVolumeCBM,
maxVolumeCBM: maxVolumeCBM,
minWeightKG: minWeightKG,
maxWeightKG: maxWeightKG,
palletCount: 0,
pricePerCBM: pricePerCBM,
pricePerKG: pricePerKG,
basePriceUSD: currency === 'USD' ? pricePerCBM : 0,
basePriceEUR: currency === 'EUR' ? pricePerCBM : 0,
currency: currency,
hasSurcharges: hasSurcharges,
surchargeBAF: '', // BAF non spécifié dans le CSV source
surchargeCAF: '', // CAF non spécifié dans le CSV source
surchargeDetails: surchargeDetails,
transitDays: transitDays,
validFrom: validFrom,
validUntil: validUntil
};
}
/**
* Main conversion function
*/
async function convertCSV() {
console.log('🔄 Conversion CSV - Tarifs Maritimes\n');
console.log(`📥 Fichier source: ${INPUT_FILE}`);
console.log(`📤 Fichier destination: ${OUTPUT_FILE}`);
console.log(`🏢 Compagnie: ${COMPANY_NAME}\n`);
try {
// Lecture du fichier source
if (!fs.existsSync(INPUT_FILE)) {
throw new Error(`Fichier source introuvable: ${INPUT_FILE}`);
}
const fileContent = fs.readFileSync(INPUT_FILE, 'utf-8');
const lines = fileContent.split('\n').filter(line => line.trim());
console.log(`📊 ${lines.length} lignes trouvées\n`);
// Parse headers
const headers = parseCSVLine(lines[0]);
console.log(`📋 Colonnes source détectées: ${headers.length}`);
// Parse data rows
const dataRows = [];
for (let i = 1; i < lines.length; i++) {
const values = parseCSVLine(lines[i]);
// Créer un objet avec les headers comme clés
const row = {};
headers.forEach((header, index) => {
row[header] = values[index] || '';
});
// Vérifier que la ligne a des données valides
if (row['Origine UN code'] && row['Destination UN code']) {
dataRows.push(row);
}
}
console.log(`${dataRows.length} lignes valides à convertir\n`);
// Conversion des lignes
const convertedRows = dataRows.map(convertRow);
// Génération du CSV de sortie
const outputLines = [OUTPUT_HEADERS.join(',')];
convertedRows.forEach(row => {
const values = OUTPUT_HEADERS.map(header => {
const value = row[header];
// Échapper les virgules et quotes
if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
});
outputLines.push(values.join(','));
});
// Écriture du fichier
fs.writeFileSync(OUTPUT_FILE, outputLines.join('\n'), 'utf-8');
console.log('✅ Conversion terminée avec succès!\n');
console.log('📊 Statistiques:');
console.log(` - Lignes converties: ${convertedRows.length}`);
console.log(` - Origines uniques: ${new Set(convertedRows.map(r => r.origin)).size}`);
console.log(` - Destinations uniques: ${new Set(convertedRows.map(r => r.destination)).size}`);
console.log(` - Devises: ${new Set(convertedRows.map(r => r.currency)).size} (${[...new Set(convertedRows.map(r => r.currency))].join(', ')})`);
// Exemples de tarifs
console.log('\n📦 Exemples de tarifs convertis:');
convertedRows.slice(0, 3).forEach((row, idx) => {
console.log(` ${idx + 1}. ${row.origin}${row.destination}`);
console.log(` Prix: ${row.pricePerCBM} ${row.currency}/CBM (${row.pricePerKG} ${row.currency}/KG)`);
console.log(` Transit: ${row.transitDays} jours`);
});
console.log(`\n🎯 Fichier prêt à importer: ${OUTPUT_FILE}`);
console.log('\n📝 Commande d\'import:');
console.log(` curl -X POST http://localhost:4000/api/v1/admin/rates/csv/upload \\`);
console.log(` -H "Authorization: Bearer $TOKEN" \\`);
console.log(` -F "file=@${OUTPUT_FILE}"`);
} catch (error) {
console.error('❌ Erreur lors de la conversion:', error.message);
process.exit(1);
}
}
// Exécution
if (require.main === module) {
convertCSV();
}
module.exports = { convertCSV };