Compare commits
No commits in common. "cb0d44bb348de8e497a2baec1b71d228104a977e" and "d809feecef340d24f8015eca7652c0ad8d66f6f2" have entirely different histories.
cb0d44bb34
...
d809feecef
@ -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": []
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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, ' ');
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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.`);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
Loading…
Reference in New Issue
Block a user