feature csv rates
This commit is contained in:
parent
634b9adc4a
commit
cb0d44bb34
@ -27,7 +27,11 @@
|
|||||||
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"SELECT id, email, role FROM users LIMIT 5;\")",
|
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"SELECT id, email, role FROM users LIMIT 5;\")",
|
||||||
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"UPDATE users SET role = ''ADMIN'' WHERE email = ''test4@xpeditis.com''; SELECT id, email, role FROM users WHERE email = ''test4@xpeditis.com'';\")",
|
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"UPDATE users SET role = ''ADMIN'' WHERE email = ''test4@xpeditis.com''; SELECT id, email, role FROM users WHERE email = ''test4@xpeditis.com'';\")",
|
||||||
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MTU5MDg0OSwiZXhwIjoxNzYxNTkxNzQ5fQ.CPFhvgASXuklZ81FiuX_XwYZfh8xKG4tNG70JQ4Dv8M\")",
|
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MTU5MDg0OSwiZXhwIjoxNzYxNTkxNzQ5fQ.CPFhvgASXuklZ81FiuX_XwYZfh8xKG4tNG70JQ4Dv8M\")",
|
||||||
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"UPDATE users SET role = ''ADMIN'' WHERE email = ''dharnaud77@hotmail.fr''; SELECT id, email, role FROM users WHERE email = ''dharnaud77@hotmail.fr'';\")"
|
"Bash(PGPASSWORD=xpeditis_dev_password psql -h localhost -p 5432 -U xpeditis -d xpeditis_dev -c \"UPDATE users SET role = ''ADMIN'' WHERE email = ''dharnaud77@hotmail.fr''; SELECT id, email, role FROM users WHERE email = ''dharnaud77@hotmail.fr'';\")",
|
||||||
|
"Bash(npm run format:*)",
|
||||||
|
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMzg1MDVkMi1hMmVlLTQ5NmMtOWNjZC1iNjUyN2FjMzcxODgiLCJlbWFpbCI6InRlc3Q0QHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiYTEyMzQ1NjctMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAxIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2MTU5Njk0MywiZXhwIjoxNzYxNTk3ODQzfQ.cwvInoHK_vR24aRRlkJGBv_VBkgyfpCwpXyrAhulQYI\")",
|
||||||
|
"Read(//Users/david/Downloads/drive-download-20251023T120052Z-1-001/**)",
|
||||||
|
"Bash(bash:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import { RolesGuard } from '../../guards/roles.guard';
|
|||||||
import { Roles } from '../../decorators/roles.decorator';
|
import { Roles } from '../../decorators/roles.decorator';
|
||||||
import { CurrentUser, UserPayload } from '../../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../../decorators/current-user.decorator';
|
||||||
import { CsvRateLoaderAdapter } from '@infrastructure/carriers/csv-loader/csv-rate-loader.adapter';
|
import { CsvRateLoaderAdapter } from '@infrastructure/carriers/csv-loader/csv-rate-loader.adapter';
|
||||||
|
import { CsvConverterService } from '@infrastructure/carriers/csv-loader/csv-converter.service';
|
||||||
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
||||||
import {
|
import {
|
||||||
CsvRateUploadDto,
|
CsvRateUploadDto,
|
||||||
@ -54,6 +55,7 @@ export class CsvRatesAdminController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly csvLoader: CsvRateLoaderAdapter,
|
private readonly csvLoader: CsvRateLoaderAdapter,
|
||||||
|
private readonly csvConverter: CsvConverterService,
|
||||||
private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository,
|
private readonly csvConfigRepository: TypeOrmCsvRateConfigRepository,
|
||||||
private readonly csvRateMapper: CsvRateMapper
|
private readonly csvRateMapper: CsvRateMapper
|
||||||
) {}
|
) {}
|
||||||
@ -139,8 +141,18 @@ export class CsvRatesAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate CSV file structure
|
// Auto-convert CSV if needed (FOB FRET → Standard format)
|
||||||
const validation = await this.csvLoader.validateCsvFile(file.filename);
|
const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName);
|
||||||
|
const filePathToValidate = conversionResult.convertedPath;
|
||||||
|
|
||||||
|
if (conversionResult.wasConverted) {
|
||||||
|
this.logger.log(
|
||||||
|
`Converted ${conversionResult.rowsConverted} rows from FOB FRET format to standard format`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate CSV file structure using the converted path
|
||||||
|
const validation = await this.csvLoader.validateCsvFile(filePathToValidate);
|
||||||
|
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
@ -152,8 +164,8 @@ export class CsvRatesAdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load rates to verify parsing
|
// Load rates to verify parsing using the converted path
|
||||||
const rates = await this.csvLoader.loadRatesFromCsv(file.filename);
|
const rates = await this.csvLoader.loadRatesFromCsv(filePathToValidate);
|
||||||
const ratesCount = rates.length;
|
const ratesCount = rates.length;
|
||||||
|
|
||||||
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);
|
this.logger.log(`Successfully parsed ${ratesCount} rates from ${file.filename}`);
|
||||||
|
|||||||
@ -164,6 +164,15 @@ export class RatesController {
|
|||||||
palletCount: dto.palletCount ?? 0,
|
palletCount: dto.palletCount ?? 0,
|
||||||
containerType: dto.containerType,
|
containerType: dto.containerType,
|
||||||
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
||||||
|
|
||||||
|
// Service requirements for detailed pricing
|
||||||
|
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
||||||
|
requiresSpecialHandling: dto.requiresSpecialHandling ?? false,
|
||||||
|
requiresTailgate: dto.requiresTailgate ?? false,
|
||||||
|
requiresStraps: dto.requiresStraps ?? false,
|
||||||
|
requiresThermalCover: dto.requiresThermalCover ?? false,
|
||||||
|
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
|
||||||
|
requiresAppointment: dto.requiresAppointment ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute CSV rate search
|
// Execute CSV rate search
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsNotEmpty, IsString, IsNumber, Min, IsOptional, ValidateNested } from 'class-validator';
|
import { IsNotEmpty, IsString, IsNumber, Min, IsOptional, ValidateNested, IsBoolean } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { RateSearchFiltersDto } from './rate-search-filters.dto';
|
import { RateSearchFiltersDto } from './rate-search-filters.dto';
|
||||||
|
|
||||||
@ -75,6 +75,70 @@ export class CsvRateSearchDto {
|
|||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => RateSearchFiltersDto)
|
@Type(() => RateSearchFiltersDto)
|
||||||
filters?: RateSearchFiltersDto;
|
filters?: RateSearchFiltersDto;
|
||||||
|
|
||||||
|
// Service requirements for detailed price calculation
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Cargo contains dangerous goods (DG)',
|
||||||
|
example: true,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
hasDangerousGoods?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Requires special handling',
|
||||||
|
example: true,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresSpecialHandling?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Requires tailgate lift',
|
||||||
|
example: false,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresTailgate?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Requires securing straps',
|
||||||
|
example: true,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresStraps?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Requires thermal protection cover',
|
||||||
|
example: false,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresThermalCover?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Contains regulated products requiring special documentation',
|
||||||
|
example: false,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
hasRegulatedProducts?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Requires delivery appointment',
|
||||||
|
example: true,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresAppointment?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -115,6 +179,90 @@ export class CsvRateSearchResponseDto {
|
|||||||
appliedFilters: RateSearchFiltersDto;
|
appliedFilters: RateSearchFiltersDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surcharge Item DTO
|
||||||
|
*/
|
||||||
|
export class SurchargeItemDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Surcharge code',
|
||||||
|
example: 'DG_FEE',
|
||||||
|
})
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Surcharge description',
|
||||||
|
example: 'Dangerous goods fee',
|
||||||
|
})
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Surcharge amount in currency',
|
||||||
|
example: 65.0,
|
||||||
|
})
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Type of surcharge calculation',
|
||||||
|
enum: ['FIXED', 'PER_UNIT', 'PERCENTAGE'],
|
||||||
|
example: 'FIXED',
|
||||||
|
})
|
||||||
|
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price Breakdown DTO
|
||||||
|
*/
|
||||||
|
export class PriceBreakdownDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Base price before any charges',
|
||||||
|
example: 0,
|
||||||
|
})
|
||||||
|
basePrice: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Charge based on volume (CBM)',
|
||||||
|
example: 150.0,
|
||||||
|
})
|
||||||
|
volumeCharge: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Charge based on weight (KG)',
|
||||||
|
example: 25.0,
|
||||||
|
})
|
||||||
|
weightCharge: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Charge for pallets',
|
||||||
|
example: 125.0,
|
||||||
|
})
|
||||||
|
palletCharge: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'List of all surcharges',
|
||||||
|
type: [SurchargeItemDto],
|
||||||
|
})
|
||||||
|
surcharges: SurchargeItemDto[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Total of all surcharges',
|
||||||
|
example: 242.0,
|
||||||
|
})
|
||||||
|
totalSurcharges: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Total price including all charges',
|
||||||
|
example: 542.0,
|
||||||
|
})
|
||||||
|
totalPrice: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Currency of the pricing',
|
||||||
|
enum: ['USD', 'EUR'],
|
||||||
|
example: 'USD',
|
||||||
|
})
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single CSV Rate Result DTO
|
* Single CSV Rate Result DTO
|
||||||
*/
|
*/
|
||||||
@ -162,6 +310,12 @@ export class CsvRateResultDto {
|
|||||||
})
|
})
|
||||||
primaryCurrency: string;
|
primaryCurrency: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Detailed price breakdown with all charges',
|
||||||
|
type: PriceBreakdownDto,
|
||||||
|
})
|
||||||
|
priceBreakdown: PriceBreakdownDto;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Whether this rate has separate surcharges',
|
description: 'Whether this rate has separate surcharges',
|
||||||
example: true,
|
example: true,
|
||||||
|
|||||||
@ -60,6 +60,21 @@ export class CsvRateMapper {
|
|||||||
priceUSD: result.calculatedPrice.usd,
|
priceUSD: result.calculatedPrice.usd,
|
||||||
priceEUR: result.calculatedPrice.eur,
|
priceEUR: result.calculatedPrice.eur,
|
||||||
primaryCurrency: result.calculatedPrice.primaryCurrency,
|
primaryCurrency: result.calculatedPrice.primaryCurrency,
|
||||||
|
priceBreakdown: {
|
||||||
|
basePrice: result.priceBreakdown.basePrice,
|
||||||
|
volumeCharge: result.priceBreakdown.volumeCharge,
|
||||||
|
weightCharge: result.priceBreakdown.weightCharge,
|
||||||
|
palletCharge: result.priceBreakdown.palletCharge,
|
||||||
|
surcharges: result.priceBreakdown.surcharges.map(s => ({
|
||||||
|
code: s.code,
|
||||||
|
description: s.description,
|
||||||
|
amount: s.amount,
|
||||||
|
type: s.type,
|
||||||
|
})),
|
||||||
|
totalSurcharges: result.priceBreakdown.totalSurcharges,
|
||||||
|
totalPrice: result.priceBreakdown.totalPrice,
|
||||||
|
currency: result.priceBreakdown.currency,
|
||||||
|
},
|
||||||
hasSurcharges: rate.hasSurcharges(),
|
hasSurcharges: rate.hasSurcharges(),
|
||||||
surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null,
|
surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null,
|
||||||
transitDays: rate.transitDays,
|
transitDays: rate.transitDays,
|
||||||
|
|||||||
@ -50,6 +50,39 @@ export interface CsvRateSearchInput {
|
|||||||
palletCount?: number; // Number of pallets (0 if none)
|
palletCount?: number; // Number of pallets (0 if none)
|
||||||
containerType?: string; // Optional container type filter
|
containerType?: string; // Optional container type filter
|
||||||
filters?: RateSearchFilters; // Advanced filters
|
filters?: RateSearchFilters; // Advanced filters
|
||||||
|
|
||||||
|
// Service requirements for price calculation
|
||||||
|
hasDangerousGoods?: boolean;
|
||||||
|
requiresSpecialHandling?: boolean;
|
||||||
|
requiresTailgate?: boolean;
|
||||||
|
requiresStraps?: boolean;
|
||||||
|
requiresThermalCover?: boolean;
|
||||||
|
hasRegulatedProducts?: boolean;
|
||||||
|
requiresAppointment?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surcharge Item - Individual fee or charge
|
||||||
|
*/
|
||||||
|
export interface SurchargeItem {
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price Breakdown - Detailed pricing calculation
|
||||||
|
*/
|
||||||
|
export interface PriceBreakdown {
|
||||||
|
basePrice: number;
|
||||||
|
volumeCharge: number;
|
||||||
|
weightCharge: number;
|
||||||
|
palletCharge: number;
|
||||||
|
surcharges: SurchargeItem[];
|
||||||
|
totalSurcharges: number;
|
||||||
|
totalPrice: number;
|
||||||
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -64,6 +97,7 @@ export interface CsvRateSearchResult {
|
|||||||
eur: number;
|
eur: number;
|
||||||
primaryCurrency: string;
|
primaryCurrency: string;
|
||||||
};
|
};
|
||||||
|
priceBreakdown: PriceBreakdown; // Detailed price calculation
|
||||||
source: 'CSV';
|
source: 'CSV';
|
||||||
matchScore: number; // 0-100, how well it matches filters
|
matchScore: number; // 0-100, how well it matches filters
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,231 @@
|
|||||||
|
import { CsvRate } from '../entities/csv-rate.entity';
|
||||||
|
|
||||||
|
export interface PriceCalculationParams {
|
||||||
|
volumeCBM: number;
|
||||||
|
weightKG: number;
|
||||||
|
palletCount: number;
|
||||||
|
hasDangerousGoods: boolean;
|
||||||
|
requiresSpecialHandling: boolean;
|
||||||
|
requiresTailgate: boolean;
|
||||||
|
requiresStraps: boolean;
|
||||||
|
requiresThermalCover: boolean;
|
||||||
|
hasRegulatedProducts: boolean;
|
||||||
|
requiresAppointment: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceBreakdown {
|
||||||
|
basePrice: number;
|
||||||
|
volumeCharge: number;
|
||||||
|
weightCharge: number;
|
||||||
|
palletCharge: number;
|
||||||
|
surcharges: SurchargeItem[];
|
||||||
|
totalSurcharges: number;
|
||||||
|
totalPrice: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SurchargeItem {
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de calcul de prix pour les tarifs CSV
|
||||||
|
* Calcule le prix total basé sur le volume, poids, palettes et services additionnels
|
||||||
|
*/
|
||||||
|
export class CsvRatePriceCalculatorService {
|
||||||
|
/**
|
||||||
|
* Calcule le prix total pour un tarif CSV donné
|
||||||
|
*/
|
||||||
|
calculatePrice(
|
||||||
|
rate: CsvRate,
|
||||||
|
params: PriceCalculationParams,
|
||||||
|
): PriceBreakdown {
|
||||||
|
// 1. Prix de base
|
||||||
|
const basePrice = rate.pricing.basePriceUSD.getAmount();
|
||||||
|
|
||||||
|
// 2. Frais au volume (USD par CBM)
|
||||||
|
const volumeCharge = rate.pricing.pricePerCBM * params.volumeCBM;
|
||||||
|
|
||||||
|
// 3. Frais au poids (USD par KG)
|
||||||
|
const weightCharge = rate.pricing.pricePerKG * params.weightKG;
|
||||||
|
|
||||||
|
// 4. Frais de palettes (25 USD par palette)
|
||||||
|
const palletCharge = params.palletCount * 25;
|
||||||
|
|
||||||
|
// 5. Surcharges standard du CSV
|
||||||
|
const standardSurcharges = this.parseStandardSurcharges(
|
||||||
|
rate.getSurchargeDetails(),
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. Surcharges additionnelles basées sur les services
|
||||||
|
const additionalSurcharges = this.calculateAdditionalSurcharges(params);
|
||||||
|
|
||||||
|
// 7. Total des surcharges
|
||||||
|
const allSurcharges = [...standardSurcharges, ...additionalSurcharges];
|
||||||
|
const totalSurcharges = allSurcharges.reduce(
|
||||||
|
(sum, s) => sum + s.amount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 8. Prix total
|
||||||
|
const totalPrice =
|
||||||
|
basePrice + volumeCharge + weightCharge + palletCharge + totalSurcharges;
|
||||||
|
|
||||||
|
return {
|
||||||
|
basePrice,
|
||||||
|
volumeCharge,
|
||||||
|
weightCharge,
|
||||||
|
palletCharge,
|
||||||
|
surcharges: allSurcharges,
|
||||||
|
totalSurcharges,
|
||||||
|
totalPrice: Math.round(totalPrice * 100) / 100, // Arrondi à 2 décimales
|
||||||
|
currency: rate.currency || 'USD',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse les surcharges standard du format CSV
|
||||||
|
* Format: "DOC:10 | ISPS:7 | HANDLING:20 W | DG_FEE:65"
|
||||||
|
*/
|
||||||
|
private parseStandardSurcharges(
|
||||||
|
surchargeDetails: string | null,
|
||||||
|
params: PriceCalculationParams,
|
||||||
|
): SurchargeItem[] {
|
||||||
|
if (!surchargeDetails) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const surcharges: SurchargeItem[] = [];
|
||||||
|
const items = surchargeDetails.split('|').map((s) => s.trim());
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const match = item.match(/^([A-Z_]+):(\d+(?:\.\d+)?)\s*([WP%]?)$/);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const [, code, amountStr, type] = match;
|
||||||
|
let amount = parseFloat(amountStr);
|
||||||
|
let surchargeType: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE' = 'FIXED';
|
||||||
|
|
||||||
|
// Calcul selon le type
|
||||||
|
if (type === 'W') {
|
||||||
|
// Par poids (W = Weight)
|
||||||
|
amount = amount * params.weightKG;
|
||||||
|
surchargeType = 'PER_UNIT';
|
||||||
|
} else if (type === 'P') {
|
||||||
|
// Par palette
|
||||||
|
amount = amount * params.palletCount;
|
||||||
|
surchargeType = 'PER_UNIT';
|
||||||
|
} else if (type === '%') {
|
||||||
|
// Pourcentage (sera appliqué sur le total)
|
||||||
|
surchargeType = 'PERCENTAGE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certaines surcharges ne s'appliquent que si certaines conditions sont remplies
|
||||||
|
if (code === 'DG_FEE' && !params.hasDangerousGoods) {
|
||||||
|
continue; // Skip DG fee si pas de marchandises dangereuses
|
||||||
|
}
|
||||||
|
|
||||||
|
surcharges.push({
|
||||||
|
code,
|
||||||
|
description: this.getSurchargeDescription(code),
|
||||||
|
amount: Math.round(amount * 100) / 100,
|
||||||
|
type: surchargeType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return surcharges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule les surcharges additionnelles basées sur les services demandés
|
||||||
|
*/
|
||||||
|
private calculateAdditionalSurcharges(
|
||||||
|
params: PriceCalculationParams,
|
||||||
|
): SurchargeItem[] {
|
||||||
|
const surcharges: SurchargeItem[] = [];
|
||||||
|
|
||||||
|
if (params.requiresSpecialHandling) {
|
||||||
|
surcharges.push({
|
||||||
|
code: 'SPECIAL_HANDLING',
|
||||||
|
description: 'Manutention particulière',
|
||||||
|
amount: 75,
|
||||||
|
type: 'FIXED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.requiresTailgate) {
|
||||||
|
surcharges.push({
|
||||||
|
code: 'TAILGATE',
|
||||||
|
description: 'Hayon élévateur',
|
||||||
|
amount: 50,
|
||||||
|
type: 'FIXED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.requiresStraps) {
|
||||||
|
surcharges.push({
|
||||||
|
code: 'STRAPS',
|
||||||
|
description: 'Sangles de sécurité',
|
||||||
|
amount: 30,
|
||||||
|
type: 'FIXED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.requiresThermalCover) {
|
||||||
|
surcharges.push({
|
||||||
|
code: 'THERMAL_COVER',
|
||||||
|
description: 'Couverture thermique',
|
||||||
|
amount: 100,
|
||||||
|
type: 'FIXED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.hasRegulatedProducts) {
|
||||||
|
surcharges.push({
|
||||||
|
code: 'REGULATED_PRODUCTS',
|
||||||
|
description: 'Produits réglementés',
|
||||||
|
amount: 80,
|
||||||
|
type: 'FIXED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.requiresAppointment) {
|
||||||
|
surcharges.push({
|
||||||
|
code: 'APPOINTMENT',
|
||||||
|
description: 'Livraison sur rendez-vous',
|
||||||
|
amount: 40,
|
||||||
|
type: 'FIXED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return surcharges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne la description d'un code de surcharge standard
|
||||||
|
*/
|
||||||
|
private getSurchargeDescription(code: string): string {
|
||||||
|
const descriptions: Record<string, string> = {
|
||||||
|
DOC: 'Documentation fee',
|
||||||
|
ISPS: 'ISPS Security',
|
||||||
|
HANDLING: 'Handling charges',
|
||||||
|
SOLAS: 'SOLAS VGM',
|
||||||
|
CUSTOMS: 'Customs clearance',
|
||||||
|
AMS_ACI: 'AMS/ACI filing',
|
||||||
|
DG_FEE: 'Dangerous goods fee',
|
||||||
|
BAF: 'Bunker Adjustment Factor',
|
||||||
|
CAF: 'Currency Adjustment Factor',
|
||||||
|
THC: 'Terminal Handling Charges',
|
||||||
|
BL_FEE: 'Bill of Lading fee',
|
||||||
|
TELEX_RELEASE: 'Telex release',
|
||||||
|
ORIGIN_CHARGES: 'Origin charges',
|
||||||
|
DEST_CHARGES: 'Destination charges',
|
||||||
|
};
|
||||||
|
|
||||||
|
return descriptions[code] || code.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
RateSearchFilters,
|
RateSearchFilters,
|
||||||
} from '../ports/in/search-csv-rates.port';
|
} from '../ports/in/search-csv-rates.port';
|
||||||
import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port';
|
import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port';
|
||||||
|
import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Rate Search Service
|
* CSV Rate Search Service
|
||||||
@ -21,7 +22,11 @@ import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port';
|
|||||||
* Pure domain logic - no framework dependencies.
|
* Pure domain logic - no framework dependencies.
|
||||||
*/
|
*/
|
||||||
export class CsvRateSearchService implements SearchCsvRatesPort {
|
export class CsvRateSearchService implements SearchCsvRatesPort {
|
||||||
constructor(private readonly csvRateLoader: CsvRateLoaderPort) {}
|
private readonly priceCalculator: CsvRatePriceCalculatorService;
|
||||||
|
|
||||||
|
constructor(private readonly csvRateLoader: CsvRateLoaderPort) {
|
||||||
|
this.priceCalculator = new CsvRatePriceCalculatorService();
|
||||||
|
}
|
||||||
|
|
||||||
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
|
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
|
||||||
const searchStartTime = new Date();
|
const searchStartTime = new Date();
|
||||||
@ -53,29 +58,35 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
|
|
||||||
// Calculate prices and create results
|
// Calculate prices and create results
|
||||||
const results: CsvRateSearchResult[] = matchingRates.map(rate => {
|
const results: CsvRateSearchResult[] = matchingRates.map(rate => {
|
||||||
const priceUSD = rate.getPriceInCurrency(volume, 'USD');
|
// Calculate detailed price breakdown
|
||||||
const priceEUR = rate.getPriceInCurrency(volume, 'EUR');
|
const priceBreakdown = this.priceCalculator.calculatePrice(rate, {
|
||||||
|
volumeCBM: input.volumeCBM,
|
||||||
|
weightKG: input.weightKG,
|
||||||
|
palletCount: input.palletCount ?? 0,
|
||||||
|
hasDangerousGoods: input.hasDangerousGoods ?? false,
|
||||||
|
requiresSpecialHandling: input.requiresSpecialHandling ?? false,
|
||||||
|
requiresTailgate: input.requiresTailgate ?? false,
|
||||||
|
requiresStraps: input.requiresStraps ?? false,
|
||||||
|
requiresThermalCover: input.requiresThermalCover ?? false,
|
||||||
|
hasRegulatedProducts: input.hasRegulatedProducts ?? false,
|
||||||
|
requiresAppointment: input.requiresAppointment ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rate,
|
rate,
|
||||||
calculatedPrice: {
|
calculatedPrice: {
|
||||||
usd: priceUSD.getAmount(),
|
usd: priceBreakdown.totalPrice,
|
||||||
eur: priceEUR.getAmount(),
|
eur: priceBreakdown.totalPrice, // TODO: Add currency conversion
|
||||||
primaryCurrency: rate.currency,
|
primaryCurrency: priceBreakdown.currency,
|
||||||
},
|
},
|
||||||
|
priceBreakdown,
|
||||||
source: 'CSV' as const,
|
source: 'CSV' as const,
|
||||||
matchScore: this.calculateMatchScore(rate, input),
|
matchScore: this.calculateMatchScore(rate, input),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by price (ascending) in primary currency
|
// Sort by total price (ascending)
|
||||||
results.sort((a, b) => {
|
results.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice);
|
||||||
const priceA =
|
|
||||||
a.calculatedPrice.primaryCurrency === 'USD' ? a.calculatedPrice.usd : a.calculatedPrice.eur;
|
|
||||||
const priceB =
|
|
||||||
b.calculatedPrice.primaryCurrency === 'USD' ? b.calculatedPrice.usd : b.calculatedPrice.eur;
|
|
||||||
return priceA - priceB;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
results,
|
results,
|
||||||
|
|||||||
@ -264,14 +264,18 @@ export class CsvConverterService {
|
|||||||
outputLines.push(values.join(','));
|
outputLines.push(values.join(','));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Écrire le fichier converti
|
// Écrire le fichier converti (garder le chemin absolu)
|
||||||
const outputPath = inputPath.replace('.csv', '-converted.csv');
|
const outputPath = inputPath.replace('.csv', '-converted.csv');
|
||||||
await fs.writeFile(outputPath, outputLines.join('\n'), 'utf-8');
|
const absoluteOutputPath = path.isAbsolute(outputPath)
|
||||||
|
? outputPath
|
||||||
|
: path.resolve(process.cwd(), outputPath);
|
||||||
|
|
||||||
this.logger.log(`Conversion completed: ${outputPath} (${convertedRows.length} rows)`);
|
await fs.writeFile(absoluteOutputPath, outputLines.join('\n'), 'utf-8');
|
||||||
|
|
||||||
|
this.logger.log(`Conversion completed: ${absoluteOutputPath} (${convertedRows.length} rows)`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
outputPath,
|
outputPath: absoluteOutputPath,
|
||||||
rowsConverted: convertedRows.length,
|
rowsConverted: convertedRows.length,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { CsvRateSearchService } from '@domain/services/csv-rate-search.service';
|
|||||||
|
|
||||||
// Infrastructure Adapters
|
// Infrastructure Adapters
|
||||||
import { CsvRateLoaderAdapter } from './csv-rate-loader.adapter';
|
import { CsvRateLoaderAdapter } from './csv-rate-loader.adapter';
|
||||||
|
import { CsvConverterService } from './csv-converter.service';
|
||||||
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
|
||||||
|
|
||||||
// Application Layer
|
// Application Layer
|
||||||
@ -33,13 +34,20 @@ import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/enti
|
|||||||
TypeOrmModule.forFeature([CsvRateConfigOrmEntity]),
|
TypeOrmModule.forFeature([CsvRateConfigOrmEntity]),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Domain Services
|
// Infrastructure Adapters (must be before services that depend on them)
|
||||||
CsvRateSearchService,
|
|
||||||
|
|
||||||
// Infrastructure Adapters
|
|
||||||
CsvRateLoaderAdapter,
|
CsvRateLoaderAdapter,
|
||||||
|
CsvConverterService,
|
||||||
TypeOrmCsvRateConfigRepository,
|
TypeOrmCsvRateConfigRepository,
|
||||||
|
|
||||||
|
// Domain Services (with factory to inject dependencies)
|
||||||
|
{
|
||||||
|
provide: CsvRateSearchService,
|
||||||
|
useFactory: (csvRateLoader: CsvRateLoaderAdapter) => {
|
||||||
|
return new CsvRateSearchService(csvRateLoader);
|
||||||
|
},
|
||||||
|
inject: [CsvRateLoaderAdapter],
|
||||||
|
},
|
||||||
|
|
||||||
// Application Mappers
|
// Application Mappers
|
||||||
CsvRateMapper,
|
CsvRateMapper,
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user