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 \"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": []
|
||||||
|
|||||||
@ -29,7 +29,6 @@ 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,
|
||||||
@ -55,7 +54,6 @@ 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
|
||||||
) {}
|
) {}
|
||||||
@ -141,18 +139,8 @@ export class CsvRatesAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Auto-convert CSV if needed (FOB FRET → Standard format)
|
// Validate CSV file structure
|
||||||
const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName);
|
const validation = await this.csvLoader.validateCsvFile(file.filename);
|
||||||
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(
|
||||||
@ -164,8 +152,8 @@ export class CsvRatesAdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load rates to verify parsing using the converted path
|
// Load rates to verify parsing
|
||||||
const rates = await this.csvLoader.loadRatesFromCsv(filePathToValidate);
|
const rates = await this.csvLoader.loadRatesFromCsv(file.filename);
|
||||||
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}`);
|
||||||
|
|||||||
@ -164,15 +164,6 @@ 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
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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 { Type } from 'class-transformer';
|
||||||
import { RateSearchFiltersDto } from './rate-search-filters.dto';
|
import { RateSearchFiltersDto } from './rate-search-filters.dto';
|
||||||
|
|
||||||
@ -75,70 +75,6 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -179,90 +115,6 @@ 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
|
||||||
*/
|
*/
|
||||||
@ -310,12 +162,6 @@ 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,
|
||||||
|
|||||||
@ -60,21 +60,6 @@ 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,
|
||||||
|
|||||||
@ -50,39 +50,6 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -97,7 +64,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
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
|
||||||
@ -22,11 +21,7 @@ import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.servi
|
|||||||
* Pure domain logic - no framework dependencies.
|
* Pure domain logic - no framework dependencies.
|
||||||
*/
|
*/
|
||||||
export class CsvRateSearchService implements SearchCsvRatesPort {
|
export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||||
private readonly priceCalculator: CsvRatePriceCalculatorService;
|
constructor(private readonly csvRateLoader: CsvRateLoaderPort) {}
|
||||||
|
|
||||||
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();
|
||||||
@ -58,35 +53,29 @@ 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 => {
|
||||||
// Calculate detailed price breakdown
|
const priceUSD = rate.getPriceInCurrency(volume, 'USD');
|
||||||
const priceBreakdown = this.priceCalculator.calculatePrice(rate, {
|
const priceEUR = rate.getPriceInCurrency(volume, 'EUR');
|
||||||
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: priceBreakdown.totalPrice,
|
usd: priceUSD.getAmount(),
|
||||||
eur: priceBreakdown.totalPrice, // TODO: Add currency conversion
|
eur: priceEUR.getAmount(),
|
||||||
primaryCurrency: priceBreakdown.currency,
|
primaryCurrency: rate.currency,
|
||||||
},
|
},
|
||||||
priceBreakdown,
|
|
||||||
source: 'CSV' as const,
|
source: 'CSV' as const,
|
||||||
matchScore: this.calculateMatchScore(rate, input),
|
matchScore: this.calculateMatchScore(rate, input),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by total price (ascending)
|
// Sort by price (ascending) in primary currency
|
||||||
results.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice);
|
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 {
|
return {
|
||||||
results,
|
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
|
// 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
|
||||||
@ -34,19 +33,12 @@ import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/enti
|
|||||||
TypeOrmModule.forFeature([CsvRateConfigOrmEntity]),
|
TypeOrmModule.forFeature([CsvRateConfigOrmEntity]),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Infrastructure Adapters (must be before services that depend on them)
|
// Domain Services
|
||||||
CsvRateLoaderAdapter,
|
CsvRateSearchService,
|
||||||
CsvConverterService,
|
|
||||||
TypeOrmCsvRateConfigRepository,
|
|
||||||
|
|
||||||
// Domain Services (with factory to inject dependencies)
|
// Infrastructure Adapters
|
||||||
{
|
CsvRateLoaderAdapter,
|
||||||
provide: CsvRateSearchService,
|
TypeOrmCsvRateConfigRepository,
|
||||||
useFactory: (csvRateLoader: CsvRateLoaderAdapter) => {
|
|
||||||
return new CsvRateSearchService(csvRateLoader);
|
|
||||||
},
|
|
||||||
inject: [CsvRateLoaderAdapter],
|
|
||||||
},
|
|
||||||
|
|
||||||
// Application Mappers
|
// Application Mappers
|
||||||
CsvRateMapper,
|
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