Compare commits

...

3 Commits

Author SHA1 Message Date
David
f5eaa4e083 Merge branch 'update_search_price_booking' into dev
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m25s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m2s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Notify Failure (push) Has been skipped
Dev CI / Frontend — Unit Tests (push) Successful in 10m41s
2026-05-12 01:24:01 +02:00
David
9acabb6859 fix api key 2026-05-12 01:23:47 +02:00
David
71d131f4cb fix search rates 2026-05-12 01:11:04 +02:00
24 changed files with 1790 additions and 2844 deletions

View File

@ -166,27 +166,16 @@ export class RatesController {
); );
try { try {
// Map DTO to domain input
const searchInput = { const searchInput = {
origin: dto.origin, origin: dto.origin,
destination: dto.destination, destination: dto.destination,
volumeCBM: dto.volumeCBM, volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG, weightKG: dto.weightKG,
palletCount: dto.palletCount ?? 0,
containerType: dto.containerType, containerType: dto.containerType,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
// Service requirements for detailed pricing
hasDangerousGoods: dto.hasDangerousGoods ?? false, hasDangerousGoods: dto.hasDangerousGoods ?? false,
requiresSpecialHandling: dto.requiresSpecialHandling ?? false, filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
requiresTailgate: dto.requiresTailgate ?? false,
requiresStraps: dto.requiresStraps ?? false,
requiresThermalCover: dto.requiresThermalCover ?? false,
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
requiresAppointment: dto.requiresAppointment ?? false,
}; };
// Execute CSV rate search
const result = await this.csvRateSearchService.execute(searchInput); const result = await this.csvRateSearchService.execute(searchInput);
// Map domain output to response DTO // Map domain output to response DTO
@ -241,27 +230,16 @@ export class RatesController {
); );
try { try {
// Map DTO to domain input
const searchInput = { const searchInput = {
origin: dto.origin, origin: dto.origin,
destination: dto.destination, destination: dto.destination,
volumeCBM: dto.volumeCBM, volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG, weightKG: dto.weightKG,
palletCount: dto.palletCount ?? 0,
containerType: dto.containerType, containerType: dto.containerType,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
// Service requirements for detailed pricing
hasDangerousGoods: dto.hasDangerousGoods ?? false, hasDangerousGoods: dto.hasDangerousGoods ?? false,
requiresSpecialHandling: dto.requiresSpecialHandling ?? false, filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
requiresTailgate: dto.requiresTailgate ?? false,
requiresStraps: dto.requiresStraps ?? false,
requiresThermalCover: dto.requiresThermalCover ?? false,
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
requiresAppointment: dto.requiresAppointment ?? false,
}; };
// Execute CSV rate search WITH OFFERS GENERATION
const result = await this.csvRateSearchService.executeWithOffers(searchInput); const result = await this.csvRateSearchService.executeWithOffers(searchInput);
// Map domain output to response DTO // Map domain output to response DTO

View File

@ -11,384 +11,192 @@ import {
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { RateSearchFiltersDto } from './rate-search-filters.dto'; import { RateSearchFiltersDto } from './rate-search-filters.dto';
/**
* CSV Rate Search Request DTO
*
* Request body for searching rates in CSV-based system
* Includes basic search parameters + optional advanced filters
*/
export class CsvRateSearchDto { export class CsvRateSearchDto {
@ApiProperty({ @ApiProperty({ description: 'Origin UN/LOCODE', example: 'FRFOS' })
description: 'Origin port code (UN/LOCODE format)',
example: 'NLRTM',
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
})
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
origin: string; origin: string;
@ApiProperty({ @ApiProperty({ description: 'Destination UN/LOCODE', example: 'CNSHA' })
description: 'Destination port code (UN/LOCODE format)',
example: 'USNYC',
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
})
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
destination: string; destination: string;
@ApiProperty({ @ApiProperty({ description: 'Volume in cubic meters (CBM)', minimum: 0.01, example: 10.5 })
description: 'Volume in cubic meters (CBM)',
minimum: 0.01,
example: 25.5,
})
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
@Min(0.01) @Min(0.01)
volumeCBM: number; volumeCBM: number;
@ApiProperty({ @ApiProperty({ description: 'Weight in kilograms', minimum: 1, example: 2500 })
description: 'Weight in kilograms',
minimum: 1,
example: 3500,
})
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
@Min(1) @Min(1)
weightKG: number; weightKG: number;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'Container type filter', example: 'LCL' })
description: 'Number of pallets (0 if no pallets)',
minimum: 0,
example: 10,
default: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
palletCount?: number;
@ApiPropertyOptional({
description: 'Container type filter (e.g., LCL, 20DRY, 40HC)',
example: 'LCL',
})
@IsOptional() @IsOptional()
@IsString() @IsString()
containerType?: string; containerType?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'Cargo contains dangerous goods', example: false })
description: 'Advanced filters for narrowing results',
type: RateSearchFiltersDto,
})
@IsOptional()
@ValidateNested()
@Type(() => RateSearchFiltersDto)
filters?: RateSearchFiltersDto;
// Service requirements for detailed price calculation
@ApiPropertyOptional({
description: 'Cargo contains dangerous goods (DG)',
example: true,
default: false,
})
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
hasDangerousGoods?: boolean; hasDangerousGoods?: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'Advanced filters', type: RateSearchFiltersDto })
description: 'Requires special handling',
example: true,
default: false,
})
@IsOptional() @IsOptional()
@IsBoolean() @ValidateNested()
requiresSpecialHandling?: boolean; @Type(() => RateSearchFiltersDto)
filters?: RateSearchFiltersDto;
@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;
} }
/**
* CSV Rate Search Response DTO
*
* Response containing matching rates with calculated prices
*/
export class CsvRateSearchResponseDto { export class CsvRateSearchResponseDto {
@ApiProperty({ @ApiProperty({ description: 'Array of matching rate results', type: [Object] })
description: 'Array of matching rate results',
type: [Object], // Will be replaced with RateResultDto
})
results: CsvRateResultDto[]; results: CsvRateResultDto[];
@ApiProperty({ @ApiProperty({ description: 'Total number of results', example: 12 })
description: 'Total number of results found',
example: 15,
})
totalResults: number; totalResults: number;
@ApiProperty({ @ApiProperty({ description: 'CSV files searched', type: [String] })
description: 'CSV files that were searched',
type: [String],
example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'],
})
searchedFiles: string[]; searchedFiles: string[];
@ApiProperty({ @ApiProperty({ description: 'Timestamp of search', example: '2026-05-11T10:30:00Z' })
description: 'Timestamp when search was executed',
example: '2025-10-23T10:30:00Z',
})
searchedAt: Date; searchedAt: Date;
@ApiProperty({ @ApiProperty({ description: 'Applied filters' })
description: 'Filters that were applied to the search',
type: RateSearchFiltersDto,
})
appliedFilters: RateSearchFiltersDto; appliedFilters: RateSearchFiltersDto;
} }
/** export class FobBreakdownDto {
* Surcharge Item DTO documentation: number;
*/ isps: number;
export class SurchargeItemDto { handling: number;
@ApiProperty({ solas: number;
description: 'Surcharge code', customs: number;
example: 'DG_FEE', ams_aci: number;
}) isf5: number;
code: string; dgAdmin: number;
@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 { export class PriceBreakdownDto {
@ApiProperty({ @ApiProperty({ description: 'Freight charge', example: 420.0 })
description: 'Base price before any charges', freightCharge: number;
example: 0,
@ApiProperty({ description: 'Freight currency', example: 'USD' })
freightCurrency: string;
@ApiProperty({ description: 'Fixed FOB charges (doc+ISPS+solas+customs+AMS+ISF5)', example: 185 })
fobFixed: number;
@ApiProperty({ description: 'FOB handling charge', example: 60 })
fobHandling: number;
@ApiProperty({ description: 'DG admin fee (FOB currency, 0 if non-DG)', example: 0 })
fobDG: number;
@ApiProperty({ description: 'FOB currency', example: 'EUR' })
fobCurrency: string;
@ApiProperty({ description: 'Itemized FOB breakdown', type: FobBreakdownDto })
fobBreakdown: FobBreakdownDto;
@ApiPropertyOptional({
description: 'DG surcharge amount (null if on_request/not_accepted)',
example: null,
}) })
basePrice: number; dgSurchargeAmount: number | null;
@ApiProperty({ description: 'DG surcharge currency', example: 'EUR' })
dgSurchargeCurrency: string;
@ApiProperty({ @ApiProperty({
description: 'Charge based on volume (CBM)', description: 'DG surcharge status',
example: 150.0, enum: ['computed', 'on_request', 'not_accepted'],
example: 'computed',
}) })
volumeCharge: number; dgSurchargeStatus: string;
@ApiProperty({ @ApiProperty({ description: 'Total freight in freightCurrency', example: 420.0 })
description: 'Charge based on weight (KG)', totalFreight: number;
example: 25.0,
})
weightCharge: number;
@ApiProperty({ @ApiProperty({ description: 'Total FOB in fobCurrency', example: 245 })
description: 'Charge for pallets', totalFob: number;
example: 125.0,
})
palletCharge: number;
@ApiProperty({ @ApiProperty({ description: 'Sum for sorting (currency-naive)', example: 665.0 })
description: 'List of all surcharges', totalPriceForSorting: number;
type: [SurchargeItemDto],
})
surcharges: SurchargeItemDto[];
@ApiProperty({ @ApiProperty({ description: 'Primary currency', example: 'USD' })
description: 'Total of all surcharges', primaryCurrency: string;
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
*/
export class CsvRateResultDto { export class CsvRateResultDto {
@ApiProperty({ @ApiProperty({ example: 'SSC Consolidation' })
description: 'Company name',
example: 'SSC Consolidation',
})
companyName: string; companyName: string;
@ApiProperty({ @ApiProperty({ example: 'bookings@ssc.com' })
description: 'Company email for booking requests',
example: 'bookings@sscconsolidation.com',
})
companyEmail: string; companyEmail: string;
@ApiProperty({ @ApiProperty({ description: 'Origin CFS name', example: 'Fos Sur Mer' })
description: 'Origin port code', originCFS: string;
example: 'NLRTM',
}) @ApiProperty({ description: 'Origin UN/LOCODE', example: 'FRFOS' })
origin: string; origin: string;
@ApiProperty({ @ApiProperty({ description: 'Port of loading', example: 'FOS SUR MER' })
description: 'Destination port code', portOfLoading: string;
example: 'USNYC',
}) @ApiProperty({ description: 'Routing type', example: 'Direct' })
routing: string;
@ApiProperty({ description: 'Destination CFS name', example: 'Shanghai' })
destinationCFS: string;
@ApiProperty({ description: 'Destination UN/LOCODE', example: 'CNSHA' })
destination: string; destination: string;
@ApiProperty({ @ApiProperty({ description: 'Destination country', example: 'China' })
description: 'Container type', destinationCountry: string;
example: 'LCL',
}) @ApiProperty({ example: 'LCL' })
containerType: string; containerType: string;
@ApiProperty({ @ApiProperty({ description: 'Detailed price breakdown', type: PriceBreakdownDto })
description: 'Calculated price in USD',
example: 1850.5,
})
priceUSD: number;
@ApiProperty({
description: 'Calculated price in EUR',
example: 1665.45,
})
priceEUR: number;
@ApiProperty({
description: 'Primary currency of the rate',
enum: ['USD', 'EUR'],
example: 'USD',
})
primaryCurrency: string;
@ApiProperty({
description: 'Detailed price breakdown with all charges',
type: PriceBreakdownDto,
})
priceBreakdown: PriceBreakdownDto; priceBreakdown: PriceBreakdownDto;
@ApiProperty({ @ApiProperty({ description: 'Departure frequency', example: 'Weekly' })
description: 'Whether this rate has separate surcharges', frequency: string;
example: true,
})
hasSurcharges: boolean;
@ApiProperty({ @ApiProperty({ description: 'Transit time (adjusted if service level)', example: 28 })
description: 'Details of surcharges if any',
example: 'BAF+CAF included',
nullable: true,
})
surchargeDetails: string | null;
@ApiProperty({
description: 'Transit time in days',
example: 28,
})
transitDays: number; transitDays: number;
@ApiProperty({ @ApiProperty({ description: 'Rate validity end date', example: '2026-12-31' })
description: 'Rate validity end date',
example: '2025-12-31',
})
validUntil: string; validUntil: string;
@ApiProperty({ @ApiProperty({ description: 'Whether DG cargo is accepted', example: true })
description: 'Source of the rate', dgAccepted: boolean;
enum: ['CSV', 'API'],
example: 'CSV',
})
source: 'CSV' | 'API';
@ApiProperty({ @ApiProperty({ description: 'DG surcharge status', example: 'computed' })
description: 'Match score (0-100) indicating how well this rate matches the search', dgSurchargeStatus: string;
minimum: 0,
maximum: 100, @ApiProperty({ description: 'Internal remarks', example: 'GR1/GR2' })
example: 95, remarks: string;
})
@ApiProperty({ example: 'CSV' })
source: 'CSV';
@ApiProperty({ description: 'Match score 0-100', example: 95 })
matchScore: number; matchScore: number;
@ApiPropertyOptional({ @ApiPropertyOptional({ enum: ['RAPID', 'STANDARD', 'ECONOMIC'] })
description: 'Service level (only present when using search-csv-offers endpoint)',
enum: ['RAPID', 'STANDARD', 'ECONOMIC'],
example: 'RAPID',
})
serviceLevel?: string; serviceLevel?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'Price multiplier for service level', example: 1.0 })
description: 'Original price before service level adjustment', priceMultiplier?: number;
example: { usd: 1500.0, eur: 1350.0 },
})
originalPrice?: {
usd: number;
eur: number;
};
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Original transit days before service level adjustment', description: 'Original transit days before service level adjustment',
example: 20, example: 28,
}) })
originalTransitDays?: number; originalTransitDays?: number;
} }

View File

@ -10,15 +10,9 @@ import {
IsString, IsString,
} from 'class-validator'; } from 'class-validator';
/**
* Rate Search Filters DTO
*
* Advanced filters for narrowing down rate search results
* All filters are optional
*/
export class RateSearchFiltersDto { export class RateSearchFiltersDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'List of company names to include in search', description: 'List of company names to include',
type: [String], type: [String],
example: ['SSC Consolidation', 'ECU Worldwide'], example: ['SSC Consolidation', 'ECU Worldwide'],
}) })
@ -28,59 +22,25 @@ export class RateSearchFiltersDto {
companies?: string[]; companies?: string[];
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Minimum volume in CBM (cubic meters)', description: 'Only show "Direct" routing (exclude transhipment)',
minimum: 0, example: false,
example: 1,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsBoolean()
@Min(0) onlyDirect?: boolean;
minVolumeCBM?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Maximum volume in CBM (cubic meters)', description: 'Exclude routes where DG is not accepted',
minimum: 0, example: false,
example: 100,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsBoolean()
@Min(0) excludeNonDgRoutes?: boolean;
maxVolumeCBM?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Minimum weight in kilograms', description: 'Minimum price (totalPriceForSorting)',
minimum: 0, minimum: 0,
example: 100, example: 500,
})
@IsOptional()
@IsNumber()
@Min(0)
minWeightKG?: number;
@ApiPropertyOptional({
description: 'Maximum weight in kilograms',
minimum: 0,
example: 15000,
})
@IsOptional()
@IsNumber()
@Min(0)
maxWeightKG?: number;
@ApiPropertyOptional({
description: 'Exact number of pallets (0 means any)',
minimum: 0,
example: 10,
})
@IsOptional()
@IsNumber()
@Min(0)
palletCount?: number;
@ApiPropertyOptional({
description: 'Minimum price in selected currency',
minimum: 0,
example: 1000,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@ -88,9 +48,9 @@ export class RateSearchFiltersDto {
minPrice?: number; minPrice?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Maximum price in selected currency', description: 'Maximum price (totalPriceForSorting)',
minimum: 0, minimum: 0,
example: 5000, example: 3000,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@ -110,7 +70,7 @@ export class RateSearchFiltersDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Maximum transit time in days', description: 'Maximum transit time in days',
minimum: 0, minimum: 0,
example: 40, example: 45,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@ -120,7 +80,7 @@ export class RateSearchFiltersDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Container types to filter by', description: 'Container types to filter by',
type: [String], type: [String],
example: ['LCL', '20DRY', '40HC'], example: ['LCL'],
}) })
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ -128,7 +88,7 @@ export class RateSearchFiltersDto {
containerTypes?: string[]; containerTypes?: string[];
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Preferred currency for price filtering', description: 'Preferred currency for price display',
enum: ['USD', 'EUR'], enum: ['USD', 'EUR'],
example: 'USD', example: 'USD',
}) })
@ -136,17 +96,9 @@ export class RateSearchFiltersDto {
@IsEnum(['USD', 'EUR']) @IsEnum(['USD', 'EUR'])
currency?: 'USD' | 'EUR'; currency?: 'USD' | 'EUR';
@ApiPropertyOptional({
description: 'Only show all-in prices (without separate surcharges)',
example: false,
})
@IsOptional()
@IsBoolean()
onlyAllInPrices?: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Departure date to check rate validity (ISO 8601)', description: 'Departure date to check rate validity (ISO 8601)',
example: '2025-06-15', example: '2026-06-15',
}) })
@IsOptional() @IsOptional()
@IsDateString() @IsDateString()

View File

@ -1,5 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { CsvRateResultDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto'; import {
CsvRateResultDto,
CsvRateSearchResponseDto,
PriceBreakdownDto,
FobBreakdownDto,
} from '../dto/csv-rate-search.dto';
import { import {
CsvRateSearchOutput, CsvRateSearchOutput,
CsvRateSearchResult, CsvRateSearchResult,
@ -9,100 +14,92 @@ import { RateSearchFiltersDto } from '../dto/rate-search-filters.dto';
import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto'; import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto';
import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity'; import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity';
/**
* CSV Rate Mapper
*
* Maps between domain entities and DTOs
* Follows hexagonal architecture principles
*/
@Injectable() @Injectable()
export class CsvRateMapper { export class CsvRateMapper {
/**
* Map DTO filters to domain filters
*/
mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined { mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined {
if (!dto) { if (!dto) return undefined;
return undefined;
}
return { return {
companies: dto.companies, companies: dto.companies,
minVolumeCBM: dto.minVolumeCBM, onlyDirect: dto.onlyDirect,
maxVolumeCBM: dto.maxVolumeCBM, excludeNonDgRoutes: dto.excludeNonDgRoutes,
minWeightKG: dto.minWeightKG,
maxWeightKG: dto.maxWeightKG,
palletCount: dto.palletCount,
minPrice: dto.minPrice, minPrice: dto.minPrice,
maxPrice: dto.maxPrice, maxPrice: dto.maxPrice,
currency: dto.currency, currency: dto.currency,
minTransitDays: dto.minTransitDays, minTransitDays: dto.minTransitDays,
maxTransitDays: dto.maxTransitDays, maxTransitDays: dto.maxTransitDays,
containerTypes: dto.containerTypes, containerTypes: dto.containerTypes,
onlyAllInPrices: dto.onlyAllInPrices,
departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined, departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined,
}; };
} }
/**
* Map domain search result to DTO
*/
mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto { mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto {
const rate = result.rate; const rate = result.rate;
const bd = result.priceBreakdown;
const fobBreakdown: FobBreakdownDto = {
documentation: bd.fobBreakdown.documentation,
isps: bd.fobBreakdown.isps,
handling: bd.fobBreakdown.handling,
solas: bd.fobBreakdown.solas,
customs: bd.fobBreakdown.customs,
ams_aci: bd.fobBreakdown.ams_aci,
isf5: bd.fobBreakdown.isf5,
dgAdmin: bd.fobBreakdown.dgAdmin,
};
const priceBreakdown: PriceBreakdownDto = {
freightCharge: bd.freightCharge,
freightCurrency: bd.freightCurrency,
fobFixed: bd.fobFixed,
fobHandling: bd.fobHandling,
fobDG: bd.fobDG,
fobCurrency: bd.fobCurrency,
fobBreakdown,
dgSurchargeAmount: bd.dgSurchargeAmount,
dgSurchargeCurrency: bd.dgSurchargeCurrency,
dgSurchargeStatus: bd.dgSurchargeStatus,
totalFreight: bd.totalFreight,
totalFob: bd.totalFob,
totalPriceForSorting: bd.totalPriceForSorting,
primaryCurrency: bd.primaryCurrency,
};
return { return {
companyName: rate.companyName, companyName: rate.companyName,
companyEmail: rate.companyEmail, companyEmail: rate.companyEmail,
origin: rate.origin.getValue(), originCFS: rate.originCFS,
destination: rate.destination.getValue(), origin: rate.originCode.getValue(),
portOfLoading: rate.portOfLoading,
routing: rate.routing,
destinationCFS: rate.destinationCFS,
destination: rate.destinationCode.getValue(),
destinationCountry: rate.destinationCountry,
containerType: rate.containerType.getValue(), containerType: rate.containerType.getValue(),
priceUSD: result.calculatedPrice.usd, priceBreakdown,
priceEUR: result.calculatedPrice.eur, frequency: rate.frequency,
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,
// Use adjusted transit days if available (service level offers), otherwise use original
transitDays: result.adjustedTransitDays ?? rate.transitDays, transitDays: result.adjustedTransitDays ?? rate.transitDays,
validUntil: rate.validity.getEndDate().toISOString().split('T')[0], validUntil: rate.validity.getEndDate().toISOString().split('T')[0],
dgAccepted: rate.isDgAccepted(),
dgSurchargeStatus: bd.dgSurchargeStatus,
remarks: rate.remarks,
source: result.source, source: result.source,
matchScore: result.matchScore, matchScore: result.matchScore,
// Include service level fields if present
serviceLevel: result.serviceLevel, serviceLevel: result.serviceLevel,
originalPrice: result.originalPrice, priceMultiplier: result.priceMultiplier,
originalTransitDays: result.originalTransitDays, originalTransitDays: result.originalTransitDays,
}; };
} }
/**
* Map domain search output to response DTO
*/
mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto { mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto {
return { return {
results: output.results.map(result => this.mapSearchResultToDto(result)), results: output.results.map(r => this.mapSearchResultToDto(r)),
totalResults: output.totalResults, totalResults: output.totalResults,
searchedFiles: output.searchedFiles, searchedFiles: output.searchedFiles,
searchedAt: output.searchedAt, searchedAt: output.searchedAt,
appliedFilters: output.appliedFilters as any, // Already matches DTO structure appliedFilters: output.appliedFilters as any,
}; };
} }
/**
* Map ORM entity to DTO
*/
mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto { mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto {
return { return {
id: entity.id, id: entity.id,
@ -118,10 +115,7 @@ export class CsvRateMapper {
}; };
} }
/**
* Map multiple config entities to DTOs
*/
mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] { mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] {
return entities.map(entity => this.mapConfigEntityToDto(entity)); return entities.map(e => this.mapConfigEntityToDto(e));
} }
} }

View File

@ -1,60 +1,69 @@
import { PortCode } from '../value-objects/port-code.vo'; import { PortCode } from '../value-objects/port-code.vo';
import { ContainerType } from '../value-objects/container-type.vo'; import { ContainerType } from '../value-objects/container-type.vo';
import { Money } from '../value-objects/money.vo';
import { Volume } from '../value-objects/volume.vo';
import { SurchargeCollection } from '../value-objects/surcharge.vo';
import { DateRange } from '../value-objects/date-range.vo'; import { DateRange } from '../value-objects/date-range.vo';
/** export type DgSurchargeValue = number | 'ON REQUEST' | 'NOT ACCEPTED';
* Volume Range - Valid range for CBM export type HandlingUnit = 'W' | 'UP'; // W = tonne revenue (max CBM/T), UP = per CBM
*/ export type FrequencyType = 'Weekly' | 'Bi-Weekly' | 'Bi-Monthly' | 'Monthly';
export interface VolumeRange {
minCBM: number; export interface FreightPricing {
maxCBM: number; freightCurrency: string;
freightRatePerCBM: number; // 0.0 = included/to negotiate
freightMinimum: number;
}
export interface FobCharges {
fobCurrency: string;
fobDocumentation: number;
fobISPS: number;
fobHandling: number;
fobHandlingUnit: HandlingUnit;
fobHandlingMinimum: number;
fobSolas: number;
fobCustoms: number;
fobAMS_ACI: number;
fobISF5: number;
fobDGAdmin: number; // Only if DG shipment
}
export interface DgSurchargeInfo {
dgSurchargeCurrency: string;
dgSurchargeRate: DgSurchargeValue;
dgSurchargeUnit: 'UP' | 'LS' | '%'; // per CBM, lump sum, or percentage
dgSurchargeMin: DgSurchargeValue;
} }
/** /**
* Weight Range - Valid range for KG * CsvRate Shipping rate from a consolidator CSV file.
*/
export interface WeightRange {
minKG: number;
maxKG: number;
}
/**
* Rate Pricing - Pricing structure for CSV rates
*/
export interface RatePricing {
pricePerCBM: number;
pricePerKG: number;
basePriceUSD: Money;
basePriceEUR: Money;
}
/**
* CSV Rate Entity
*
* Represents a shipping rate loaded from CSV file.
* Contains all information needed to calculate freight costs.
* *
* Business Rules: * Business Rules:
* - Price is calculated as: max(volumeCBM * pricePerCBM, weightKG * pricePerKG) + surcharges * - Route matching uses originCode + destinationCode (UN/LOCODE)
* - Rate must be valid (within validity period) to be used * - Price = max(freightRatePerCBM×V, freightMinimum) + FOB fixed + handling
* - Volume and weight must be within specified ranges * - FOB and freight may be in different currencies
* - DG surcharge applies only when hasDangerousGoods = true
*/ */
export class CsvRate { export class CsvRate {
constructor( constructor(
// Supplier identity
public readonly companyName: string, public readonly companyName: string,
public readonly companyEmail: string, public readonly companyEmail: string,
public readonly origin: PortCode, // Route geography
public readonly destination: PortCode, public readonly originCFS: string,
public readonly originCode: PortCode,
public readonly portOfLoading: string,
public readonly routing: string,
public readonly destinationCFS: string,
public readonly destinationCode: PortCode,
public readonly destinationCountry: string,
// Container
public readonly containerType: ContainerType, public readonly containerType: ContainerType,
public readonly volumeRange: VolumeRange, // Pricing
public readonly weightRange: WeightRange, public readonly freight: FreightPricing,
public readonly palletCount: number, public readonly fob: FobCharges,
public readonly pricing: RatePricing, public readonly dgSurcharge: DgSurchargeInfo,
public readonly currency: string, // Primary currency (USD or EUR) // Metadata
public readonly surcharges: SurchargeCollection, public readonly remarks: string,
public readonly frequency: FrequencyType,
public readonly transitDays: number, public readonly transitDays: number,
public readonly validity: DateRange public readonly validity: DateRange
) { ) {
@ -62,178 +71,56 @@ export class CsvRate {
} }
private validate(): void { private validate(): void {
if (!this.companyName || this.companyName.trim().length === 0) { if (!this.companyName?.trim()) throw new Error('Company name is required');
throw new Error('Company name is required'); if (!this.companyEmail?.trim()) throw new Error('Company email is required');
if (this.transitDays <= 0) throw new Error('Transit days must be positive');
if (this.freight.freightMinimum < 0) throw new Error('Freight minimum cannot be negative');
if (this.fob.fobHandling < 0) throw new Error('FOB handling cannot be negative');
} }
if (!this.companyEmail || this.companyEmail.trim().length === 0) {
throw new Error('Company email is required');
}
if (this.volumeRange.minCBM < 0 || this.volumeRange.maxCBM < 0) {
throw new Error('Volume range cannot be negative');
}
if (this.volumeRange.minCBM > this.volumeRange.maxCBM) {
throw new Error('Min volume cannot be greater than max volume');
}
if (this.weightRange.minKG < 0 || this.weightRange.maxKG < 0) {
throw new Error('Weight range cannot be negative');
}
if (this.weightRange.minKG > this.weightRange.maxKG) {
throw new Error('Min weight cannot be greater than max weight');
}
if (this.palletCount < 0) {
throw new Error('Pallet count cannot be negative');
}
if (this.pricing.pricePerCBM < 0 || this.pricing.pricePerKG < 0) {
throw new Error('Prices cannot be negative');
}
if (this.transitDays <= 0) {
throw new Error('Transit days must be positive');
}
if (this.currency !== 'USD' && this.currency !== 'EUR') {
throw new Error('Currency must be USD or EUR');
}
}
/**
* Calculate total price for given volume and weight
*
* Business Logic:
* 1. Calculate volume-based price: volumeCBM * pricePerCBM
* 2. Calculate weight-based price: weightKG * pricePerKG
* 3. Take the maximum (freight class rule)
* 4. Add surcharges
*/
calculatePrice(volume: Volume): Money {
// Freight class rule: max(volume price, weight price)
const freightPrice = volume.calculateFreightPrice(
this.pricing.pricePerCBM,
this.pricing.pricePerKG
);
// Create Money object in the rate's currency
let totalPrice = Money.create(freightPrice, this.currency);
// Add surcharges in the same currency
const surchargeTotal = this.surcharges.getTotalAmount(this.currency);
totalPrice = totalPrice.add(surchargeTotal);
return totalPrice;
}
/**
* Get price in specific currency (USD or EUR)
*/
getPriceInCurrency(volume: Volume, targetCurrency: 'USD' | 'EUR'): Money {
const price = this.calculatePrice(volume);
// If already in target currency, return as-is
if (price.getCurrency() === targetCurrency) {
return price;
}
// Otherwise, use the pre-calculated base price in target currency
// and recalculate proportionally
const basePriceInPrimaryCurrency =
this.currency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
const basePriceInTargetCurrency =
targetCurrency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
// Calculate conversion ratio
const ratio = basePriceInTargetCurrency.getAmount() / basePriceInPrimaryCurrency.getAmount();
// Apply ratio to calculated price
const convertedAmount = price.getAmount() * ratio;
return Money.create(convertedAmount, targetCurrency);
}
/**
* Check if rate is valid for a specific date
*/
isValidForDate(date: Date): boolean { isValidForDate(date: Date): boolean {
return this.validity.contains(date); return this.validity.contains(date);
} }
/**
* Check if rate is currently valid (today is within validity period)
*/
isCurrentlyValid(): boolean { isCurrentlyValid(): boolean {
return this.validity.isCurrentRange(); return this.validity.isCurrentRange();
} }
/**
* Check if volume and weight match this rate's range
*/
matchesVolume(volume: Volume): boolean {
return volume.isWithinRange(
this.volumeRange.minCBM,
this.volumeRange.maxCBM,
this.weightRange.minKG,
this.weightRange.maxKG
);
}
/**
* Check if pallet count matches
* 0 means "any pallet count" (flexible)
* Otherwise must match exactly or be within range
*/
matchesPalletCount(palletCount: number): boolean {
// If rate has 0 pallets, it's flexible
if (this.palletCount === 0) {
return true;
}
// Otherwise must match exactly
return this.palletCount === palletCount;
}
/**
* Check if rate matches a specific route
*/
matchesRoute(origin: PortCode, destination: PortCode): boolean { matchesRoute(origin: PortCode, destination: PortCode): boolean {
return this.origin.equals(origin) && this.destination.equals(destination); return this.originCode.equals(origin) && this.destinationCode.equals(destination);
} }
/** isDgAccepted(): boolean {
* Check if rate has separate surcharges return this.dgSurcharge.dgSurchargeRate !== 'NOT ACCEPTED';
*/
hasSurcharges(): boolean {
return !this.surcharges.isEmpty();
} }
/** isDgOnRequest(): boolean {
* Get surcharge details as formatted string return this.dgSurcharge.dgSurchargeRate === 'ON REQUEST';
*/
getSurchargeDetails(): string {
return this.surcharges.getDetails();
} }
/** isDirectRoute(): boolean {
* Check if this is an "all-in" rate (no separate surcharges) return this.routing.trim().toLowerCase() === 'direct';
*/ }
isAllInPrice(): boolean {
return this.surcharges.isEmpty(); getFrequencyScore(): number {
switch (this.frequency) {
case 'Weekly':
return 4;
case 'Bi-Weekly':
return 3;
case 'Bi-Monthly':
return 2;
case 'Monthly':
return 1;
default:
return 2;
}
} }
/**
* Get route description
*/
getRouteDescription(): string { getRouteDescription(): string {
return `${this.origin.getValue()}${this.destination.getValue()}`; return `${this.originCode.getValue()}${this.destinationCode.getValue()}`;
} }
/**
* Get company and route summary
*/
getSummary(): string { getSummary(): string {
return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`; return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`;
} }

View File

@ -1,160 +1,73 @@
import { CsvRate } from '../../entities/csv-rate.entity'; import { CsvRate } from '../../entities/csv-rate.entity';
import { ServiceLevel } from '../../services/rate-offer-generator.service'; import { ServiceLevel } from '../../services/rate-offer-generator.service';
import { PriceBreakdown } from '../../services/csv-rate-price-calculator.service';
export { PriceBreakdown };
/** /**
* Advanced Rate Search Filters * Filters for narrowing CSV rate search results.
* * Volume/weight range filters removed new schema has no per-rate volume limits.
* Filters for narrowing down rate search results
*/ */
export interface RateSearchFilters { export interface RateSearchFilters {
// Company filters companies?: string[];
companies?: string[]; // List of company names to include
// Volume/Weight filters // Price filter (applied to totalPriceForSorting)
minVolumeCBM?: number;
maxVolumeCBM?: number;
minWeightKG?: number;
maxWeightKG?: number;
palletCount?: number; // Exact pallet count (0 = any)
// Price filters
minPrice?: number; minPrice?: number;
maxPrice?: number; maxPrice?: number;
currency?: 'USD' | 'EUR'; // Preferred currency for filtering currency?: 'USD' | 'EUR';
// Transit filters // Transit filter
minTransitDays?: number; minTransitDays?: number;
maxTransitDays?: number; maxTransitDays?: number;
// Container type filters // Route filter
containerTypes?: string[]; // e.g., ['LCL', '20DRY', '40HC'] onlyDirect?: boolean; // Only show "Direct" routing
// Surcharge filters // Container type filter
onlyAllInPrices?: boolean; // Only show rates without separate surcharges containerTypes?: string[];
// Date filters // Date filter
departureDate?: Date; // Filter by validity for specific date departureDate?: Date;
// Service level filter // Service level filter (for offers endpoint)
serviceLevels?: ServiceLevel[]; // Filter by service level (RAPID, STANDARD, ECONOMIC) serviceLevels?: ServiceLevel[];
// DG filter
excludeNonDgRoutes?: boolean; // Only show DG-accepted routes
} }
/**
* CSV Rate Search Input
*
* Parameters for searching rates in CSV system
*/
export interface CsvRateSearchInput { export interface CsvRateSearchInput {
origin: string; // Port code (UN/LOCODE) origin: string; // UN/LOCODE
destination: string; // Port code (UN/LOCODE) destination: string; // UN/LOCODE
volumeCBM: number; // Volume in cubic meters volumeCBM: number;
weightKG: number; // Weight in kilograms weightKG: number;
palletCount?: number; // Number of pallets (0 if none) containerType?: string;
containerType?: string; // Optional container type filter
filters?: RateSearchFilters; // Advanced filters
// Service requirements for price calculation
hasDangerousGoods?: boolean; hasDangerousGoods?: boolean;
requiresSpecialHandling?: boolean; filters?: RateSearchFilters;
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;
}
/**
* CSV Rate Search Result
*
* Single rate result with calculated price
*/
export interface CsvRateSearchResult { export interface CsvRateSearchResult {
rate: CsvRate; rate: CsvRate;
calculatedPrice: { priceBreakdown: PriceBreakdown;
usd: number;
eur: number;
primaryCurrency: string;
};
priceBreakdown: PriceBreakdown; // Detailed price calculation
source: 'CSV'; source: 'CSV';
matchScore: number; // 0-100, how well it matches filters matchScore: number;
serviceLevel?: ServiceLevel; // Service level (RAPID, STANDARD, ECONOMIC) if offers are generated serviceLevel?: ServiceLevel;
originalPrice?: { priceMultiplier?: number;
usd: number; originalTransitDays?: number;
eur: number; adjustedTransitDays?: number;
}; // Original price before service level adjustment
originalTransitDays?: number; // Original transit days before service level adjustment
adjustedTransitDays?: number; // Adjusted transit days (for service level offers)
} }
/**
* CSV Rate Search Output
*
* Results from CSV rate search
*/
export interface CsvRateSearchOutput { export interface CsvRateSearchOutput {
results: CsvRateSearchResult[]; results: CsvRateSearchResult[];
totalResults: number; totalResults: number;
searchedFiles: string[]; // CSV files searched searchedFiles: string[];
searchedAt: Date; searchedAt: Date;
appliedFilters: RateSearchFilters; appliedFilters: RateSearchFilters;
} }
/**
* Search CSV Rates Port (Input Port)
*
* Use case for searching rates in CSV-based system
* Supports advanced filters for precise rate matching
*/
export interface SearchCsvRatesPort { export interface SearchCsvRatesPort {
/**
* Execute CSV rate search with filters
* @param input - Search parameters and filters
* @returns Matching rates with calculated prices
*/
execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>; execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>;
/**
* Execute CSV rate search with service level offers generation
* Generates 3 offers (RAPID, STANDARD, ECONOMIC) for each matching rate
* @param input - Search parameters and filters
* @returns Matching rates with 3 service level variants each
*/
executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>; executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>;
/**
* Get available companies in CSV system
* @returns List of company names that have CSV rates
*/
getAvailableCompanies(): Promise<string[]>; getAvailableCompanies(): Promise<string[]>;
/**
* Get available container types in CSV system
* @returns List of container types available
*/
getAvailableContainerTypes(): Promise<string[]>; getAvailableContainerTypes(): Promise<string[]>;
} }

View File

@ -3,217 +3,152 @@ import { CsvRate } from '../entities/csv-rate.entity';
export interface PriceCalculationParams { export interface PriceCalculationParams {
volumeCBM: number; volumeCBM: number;
weightKG: number; weightKG: number;
palletCount: number; hasDangerousGoods?: boolean;
hasDangerousGoods: boolean;
requiresSpecialHandling: boolean;
requiresTailgate: boolean;
requiresStraps: boolean;
requiresThermalCover: boolean;
hasRegulatedProducts: boolean;
requiresAppointment: boolean;
} }
export interface FobBreakdown {
documentation: number;
isps: number;
handling: number;
solas: number;
customs: number;
ams_aci: number;
isf5: number;
dgAdmin: number;
}
export type DgSurchargeStatus = 'computed' | 'on_request' | 'not_accepted';
export interface PriceBreakdown { export interface PriceBreakdown {
basePrice: number; // Freight (in freightCurrency)
volumeCharge: number; freightCharge: number;
weightCharge: number; freightCurrency: string;
palletCharge: number;
surcharges: SurchargeItem[];
totalSurcharges: number;
totalPrice: number;
currency: string;
}
export interface SurchargeItem { // FOB charges (in fobCurrency)
code: string; fobFixed: number; // doc + ISPS + solas + customs + AMS_ACI + ISF5
description: string; fobHandling: number;
amount: number; fobDG: number; // fobDGAdmin only if DG
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE'; fobCurrency: string;
fobBreakdown: FobBreakdown;
// DG surcharge (fobCurrency or dgSurchargeCurrency)
dgSurchargeAmount: number | null; // null when on_request or not_accepted
dgSurchargeCurrency: string;
dgSurchargeStatus: DgSurchargeStatus;
// Totals (each in their own currency)
totalFreight: number; // = freightCharge in freightCurrency
totalFob: number; // = fobFixed + fobHandling + fobDG + dgSurcharge in fobCurrency
// Used for sorting/comparison only — naive sum treating both currencies as equal
// Callers should be aware of potential currency mismatch
totalPriceForSorting: number;
primaryCurrency: string;
} }
/** /**
* Service de calcul de prix pour les tarifs CSV * Calculates price for a CSV rate given volume and weight.
* Calcule le prix total basé sur le volume, poids, palettes et services additionnels *
* Formula:
* Fret = max(freightRatePerCBM × V, freightMinimum)
* Handling = max(fobHandling × max(V, W_tonnes), fobHandlingMinimum) [if unit=W]
* = max(fobHandling × V, fobHandlingMinimum) [if unit=UP]
* FOB fixed = doc + ISPS + solas + customs + AMS_ACI + ISF5
* Total = Fret (freightCurrency) + FOB_fixed + Handling (fobCurrency)
*/ */
export class CsvRatePriceCalculatorService { export class CsvRatePriceCalculatorService {
/**
* Calcule le prix total pour un tarif CSV donné
*/
calculatePrice(rate: CsvRate, params: PriceCalculationParams): PriceBreakdown { calculatePrice(rate: CsvRate, params: PriceCalculationParams): PriceBreakdown {
// 1. Prix de base const V = params.volumeCBM;
const basePrice = rate.pricing.basePriceUSD.getAmount(); const W = params.weightKG / 1000; // convert KG → tonnes for W unit
const isDG = params.hasDangerousGoods ?? false;
// 2. Frais au volume (USD par CBM) // 1. Freight charge
const volumeCharge = rate.pricing.pricePerCBM * params.volumeCBM; const freightCharge =
rate.freight.freightRatePerCBM > 0
? Math.max(rate.freight.freightRatePerCBM * V, rate.freight.freightMinimum)
: rate.freight.freightMinimum;
// 3. Frais au poids (USD par KG) // 2. Handling — "W" = tonne revenue (max of CBM and tonnes), "UP" = per CBM
const weightCharge = rate.pricing.pricePerKG * params.weightKG; const handlingBase = rate.fob.fobHandlingUnit === 'W' ? Math.max(V, W) : V;
const fobHandling = Math.max(rate.fob.fobHandling * handlingBase, rate.fob.fobHandlingMinimum);
// 4. Frais de palettes (25 USD par palette) // 3. FOB fixed charges
const palletCharge = params.palletCount * 25; const fobFixed =
rate.fob.fobDocumentation +
rate.fob.fobISPS +
rate.fob.fobSolas +
rate.fob.fobCustoms +
rate.fob.fobAMS_ACI +
rate.fob.fobISF5;
// 5. Surcharges standard du CSV // 4. DG admin (FOB currency, only if DG)
const standardSurcharges = this.parseStandardSurcharges(rate.getSurchargeDetails(), params); const fobDG = isDG ? rate.fob.fobDGAdmin : 0;
// 6. Surcharges additionnelles basées sur les services // 5. DG surcharge (own currency, only if DG)
const additionalSurcharges = this.calculateAdditionalSurcharges(params); let dgSurchargeAmount: number | null = null;
let dgSurchargeStatus: DgSurchargeStatus = 'computed';
// 7. Total des surcharges if (isDG) {
const allSurcharges = [...standardSurcharges, ...additionalSurcharges]; const dgRate = rate.dgSurcharge.dgSurchargeRate;
const totalSurcharges = allSurcharges.reduce((sum, s) => sum + s.amount, 0); if (dgRate === 'NOT ACCEPTED') {
dgSurchargeStatus = 'not_accepted';
} else if (dgRate === 'ON REQUEST') {
dgSurchargeStatus = 'on_request';
} else {
dgSurchargeStatus = 'computed';
const dgNum = typeof dgRate === 'number' ? dgRate : parseFloat(String(dgRate));
let rawDG = 0;
switch (rate.dgSurcharge.dgSurchargeUnit) {
case 'UP':
rawDG = dgNum * V;
break;
case 'LS':
rawDG = dgNum;
break;
case '%':
rawDG = freightCharge * (dgNum / 100);
break;
}
const dgMin =
typeof rate.dgSurcharge.dgSurchargeMin === 'number' ? rate.dgSurcharge.dgSurchargeMin : 0;
dgSurchargeAmount = Math.max(rawDG, dgMin);
}
}
// 8. Prix total // 6. Total FOB (in fobCurrency)
const totalPrice = basePrice + volumeCharge + weightCharge + palletCharge + totalSurcharges; const totalFob = fobFixed + fobHandling + fobDG + (dgSurchargeAmount ?? 0);
// 7. Naive sum for sorting (ignores currency differences)
const totalPriceForSorting = freightCharge + totalFob;
return { return {
basePrice, freightCharge: round2(freightCharge),
volumeCharge, freightCurrency: rate.freight.freightCurrency,
weightCharge, fobFixed: round2(fobFixed),
palletCharge, fobHandling: round2(fobHandling),
surcharges: allSurcharges, fobDG: round2(fobDG),
totalSurcharges, fobCurrency: rate.fob.fobCurrency,
totalPrice: Math.round(totalPrice * 100) / 100, // Arrondi à 2 décimales fobBreakdown: {
currency: rate.currency || 'USD', documentation: rate.fob.fobDocumentation,
isps: rate.fob.fobISPS,
handling: round2(fobHandling),
solas: rate.fob.fobSolas,
customs: rate.fob.fobCustoms,
ams_aci: rate.fob.fobAMS_ACI,
isf5: rate.fob.fobISF5,
dgAdmin: isDG ? rate.fob.fobDGAdmin : 0,
},
dgSurchargeAmount: dgSurchargeAmount !== null ? round2(dgSurchargeAmount) : null,
dgSurchargeCurrency: rate.dgSurcharge.dgSurchargeCurrency,
dgSurchargeStatus,
totalFreight: round2(freightCharge),
totalFob: round2(totalFob),
totalPriceForSorting: round2(totalPriceForSorting),
primaryCurrency: rate.freight.freightCurrency,
}; };
} }
/**
* 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[] = []; function round2(n: number): number {
const items = surchargeDetails.split('|').map(s => s.trim()); return Math.round(n * 100) / 100;
for (const item of items) {
const match = item.match(/^([A-Z_]+):(\d+(?:\.\d+)?)\s*([WP%]?)$/);
if (!match) continue;
const [, code, amountStr, type] = match;
let amount = parseFloat(amountStr);
let surchargeType: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE' = 'FIXED';
// Calcul selon le type
if (type === 'W') {
// Par poids (W = Weight)
amount = amount * params.weightKG;
surchargeType = 'PER_UNIT';
} else if (type === 'P') {
// Par palette
amount = amount * params.palletCount;
surchargeType = 'PER_UNIT';
} else if (type === '%') {
// Pourcentage (sera appliqué sur le total)
surchargeType = 'PERCENTAGE';
}
// Certaines surcharges ne s'appliquent que si certaines conditions sont remplies
if (code === 'DG_FEE' && !params.hasDangerousGoods) {
continue; // Skip DG fee si pas de marchandises dangereuses
}
surcharges.push({
code,
description: this.getSurchargeDescription(code),
amount: Math.round(amount * 100) / 100,
type: surchargeType,
});
}
return surcharges;
}
/**
* Calcule les surcharges additionnelles basées sur les services demandés
*/
private calculateAdditionalSurcharges(params: PriceCalculationParams): SurchargeItem[] {
const surcharges: SurchargeItem[] = [];
if (params.requiresSpecialHandling) {
surcharges.push({
code: 'SPECIAL_HANDLING',
description: 'Manutention particulière',
amount: 75,
type: 'FIXED',
});
}
if (params.requiresTailgate) {
surcharges.push({
code: 'TAILGATE',
description: 'Hayon élévateur',
amount: 50,
type: 'FIXED',
});
}
if (params.requiresStraps) {
surcharges.push({
code: 'STRAPS',
description: 'Sangles de sécurité',
amount: 30,
type: 'FIXED',
});
}
if (params.requiresThermalCover) {
surcharges.push({
code: 'THERMAL_COVER',
description: 'Couverture thermique',
amount: 100,
type: 'FIXED',
});
}
if (params.hasRegulatedProducts) {
surcharges.push({
code: 'REGULATED_PRODUCTS',
description: 'Produits réglementés',
amount: 80,
type: 'FIXED',
});
}
if (params.requiresAppointment) {
surcharges.push({
code: 'APPOINTMENT',
description: 'Livraison sur rendez-vous',
amount: 40,
type: 'FIXED',
});
}
return surcharges;
}
/**
* Retourne la description d'un code de surcharge standard
*/
private getSurchargeDescription(code: string): string {
const descriptions: Record<string, string> = {
DOC: 'Documentation fee',
ISPS: 'ISPS Security',
HANDLING: 'Handling charges',
SOLAS: 'SOLAS VGM',
CUSTOMS: 'Customs clearance',
AMS_ACI: 'AMS/ACI filing',
DG_FEE: 'Dangerous goods fee',
BAF: 'Bunker Adjustment Factor',
CAF: 'Currency Adjustment Factor',
THC: 'Terminal Handling Charges',
BL_FEE: 'Bill of Lading fee',
TELEX_RELEASE: 'Telex release',
ORIGIN_CHARGES: 'Origin charges',
DEST_CHARGES: 'Destination charges',
};
return descriptions[code] || code.replace(/_/g, ' ');
}
} }

View File

@ -1,7 +1,6 @@
import { CsvRate } from '../entities/csv-rate.entity'; import { CsvRate } from '../entities/csv-rate.entity';
import { PortCode } from '../value-objects/port-code.vo'; import { PortCode } from '../value-objects/port-code.vo';
import { ContainerType } from '../value-objects/container-type.vo'; import { ContainerType } from '../value-objects/container-type.vo';
import { Volume } from '../value-objects/volume.vo';
import { import {
SearchCsvRatesPort, SearchCsvRatesPort,
CsvRateSearchInput, CsvRateSearchInput,
@ -11,11 +10,8 @@ import {
} from '@domain/ports/in/search-csv-rates.port'; } from '@domain/ports/in/search-csv-rates.port';
import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port'; import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port';
import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service'; import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service';
import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.service'; import { RateOfferGeneratorService } from './rate-offer-generator.service';
/**
* Config Metadata Interface (to avoid circular dependency)
*/
interface CsvRateConfig { interface CsvRateConfig {
companyName: string; companyName: string;
csvFilePath: string; csvFilePath: string;
@ -25,21 +21,10 @@ interface CsvRateConfig {
}; };
} }
/**
* Config Repository Port (simplified interface)
*/
export interface CsvRateConfigRepositoryPort { export interface CsvRateConfigRepositoryPort {
findActiveConfigs(): Promise<CsvRateConfig[]>; findActiveConfigs(): Promise<CsvRateConfig[]>;
} }
/**
* CSV Rate Search Service
*
* Domain service implementing CSV rate search use case.
* Applies business rules for matching rates and filtering.
*
* Pure domain logic - no framework dependencies.
*/
export class CsvRateSearchService implements SearchCsvRatesPort { export class CsvRateSearchService implements SearchCsvRatesPort {
private readonly priceCalculator: CsvRatePriceCalculatorService; private readonly priceCalculator: CsvRatePriceCalculatorService;
private readonly offerGenerator: RateOfferGeneratorService; private readonly offerGenerator: RateOfferGeneratorService;
@ -54,63 +39,39 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> { async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
const searchStartTime = new Date(); const searchStartTime = new Date();
// Parse and validate input
const origin = PortCode.create(input.origin); const origin = PortCode.create(input.origin);
const destination = PortCode.create(input.destination); const destination = PortCode.create(input.destination);
const volume = new Volume(input.volumeCBM, input.weightKG);
const palletCount = input.palletCount ?? 0;
// Load all CSV rates
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
// Apply route and volume matching
let matchingRates = this.filterByRoute(allRates, origin, destination); let matchingRates = this.filterByRoute(allRates, origin, destination);
matchingRates = this.filterByVolume(matchingRates, volume);
matchingRates = this.filterByPalletCount(matchingRates, palletCount);
// Apply container type filter if specified
if (input.containerType) { if (input.containerType) {
const containerType = ContainerType.create(input.containerType); const containerType = ContainerType.create(input.containerType);
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType)); matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
} }
// Apply advanced filters
if (input.filters) { if (input.filters) {
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume); matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, input);
} }
// Calculate prices and create results
const results: CsvRateSearchResult[] = matchingRates.map(rate => { const results: CsvRateSearchResult[] = matchingRates.map(rate => {
// Calculate detailed price breakdown
const priceBreakdown = this.priceCalculator.calculatePrice(rate, { const priceBreakdown = this.priceCalculator.calculatePrice(rate, {
volumeCBM: input.volumeCBM, volumeCBM: input.volumeCBM,
weightKG: input.weightKG, weightKG: input.weightKG,
palletCount: input.palletCount ?? 0,
hasDangerousGoods: input.hasDangerousGoods ?? false, 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: {
usd: priceBreakdown.totalPrice,
eur: priceBreakdown.totalPrice, // TODO: Add currency conversion
primaryCurrency: priceBreakdown.currency,
},
priceBreakdown, priceBreakdown,
source: 'CSV' as const, source: 'CSV' as const,
matchScore: this.calculateMatchScore(rate, input), matchScore: this.calculateMatchScore(rate),
}; };
}); });
// Sort by total price (ascending) results.sort(
results.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice); (a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting
);
return { return {
results, results,
@ -122,101 +83,67 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
} }
/** /**
* Execute CSV rate search with service level offers generation * Search with service level offers returns 3 variants per rate (ECONOMIC / STANDARD / RAPID).
* Generates 3 offers (RAPID, STANDARD, ECONOMIC) for each matching rate * Price multipliers (0.85 / 1.0 / 1.2) are applied to totalPriceForSorting.
*/ */
async executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> { async executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
const searchStartTime = new Date(); const searchStartTime = new Date();
// Parse and validate input
const origin = PortCode.create(input.origin); const origin = PortCode.create(input.origin);
const destination = PortCode.create(input.destination); const destination = PortCode.create(input.destination);
const volume = new Volume(input.volumeCBM, input.weightKG);
const palletCount = input.palletCount ?? 0;
// Load all CSV rates
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
// Apply route and volume matching
let matchingRates = this.filterByRoute(allRates, origin, destination); let matchingRates = this.filterByRoute(allRates, origin, destination);
matchingRates = this.filterByVolume(matchingRates, volume);
matchingRates = this.filterByPalletCount(matchingRates, palletCount);
// Apply container type filter if specified
if (input.containerType) { if (input.containerType) {
const containerType = ContainerType.create(input.containerType); const containerType = ContainerType.create(input.containerType);
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType)); matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
} }
// Apply advanced filters (before generating offers)
if (input.filters) { if (input.filters) {
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume); matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, input);
} }
// Filter eligible rates for offer generation
const eligibleRates = this.offerGenerator.filterEligibleRates(matchingRates); const eligibleRates = this.offerGenerator.filterEligibleRates(matchingRates);
// Generate 3 offers (RAPID, STANDARD, ECONOMIC) for each eligible rate
const allOffers = this.offerGenerator.generateOffersForRates(eligibleRates); const allOffers = this.offerGenerator.generateOffersForRates(eligibleRates);
// Convert offers to search results
const results: CsvRateSearchResult[] = allOffers.map(offer => { const results: CsvRateSearchResult[] = allOffers.map(offer => {
// Calculate detailed price breakdown with adjusted prices
const priceBreakdown = this.priceCalculator.calculatePrice(offer.rate, { const priceBreakdown = this.priceCalculator.calculatePrice(offer.rate, {
volumeCBM: input.volumeCBM, volumeCBM: input.volumeCBM,
weightKG: input.weightKG, weightKG: input.weightKG,
palletCount: input.palletCount ?? 0,
hasDangerousGoods: input.hasDangerousGoods ?? false, 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,
}); });
// Apply service level price adjustment to the total price const multiplier = offer.priceMultiplier;
const adjustedTotalPrice = const adjustedBreakdown = {
priceBreakdown.totalPrice * ...priceBreakdown,
(offer.serviceLevel === ServiceLevel.RAPID freightCharge: round2(priceBreakdown.freightCharge * multiplier),
? 1.2 totalFreight: round2(priceBreakdown.totalFreight * multiplier),
: offer.serviceLevel === ServiceLevel.ECONOMIC totalFob: round2(priceBreakdown.totalFob * multiplier),
? 0.85 totalPriceForSorting: round2(priceBreakdown.totalPriceForSorting * multiplier),
: 1.0); };
return { return {
rate: offer.rate, rate: offer.rate,
calculatedPrice: { priceBreakdown: adjustedBreakdown,
usd: adjustedTotalPrice,
eur: adjustedTotalPrice, // TODO: Add currency conversion
primaryCurrency: priceBreakdown.currency,
},
priceBreakdown: {
...priceBreakdown,
totalPrice: adjustedTotalPrice,
},
source: 'CSV' as const, source: 'CSV' as const,
matchScore: this.calculateMatchScore(offer.rate, input), matchScore: this.calculateMatchScore(offer.rate),
serviceLevel: offer.serviceLevel, serviceLevel: offer.serviceLevel,
originalPrice: { priceMultiplier: offer.priceMultiplier,
usd: offer.originalPriceUSD,
eur: offer.originalPriceEUR,
},
originalTransitDays: offer.originalTransitDays, originalTransitDays: offer.originalTransitDays,
adjustedTransitDays: offer.adjustedTransitDays, adjustedTransitDays: offer.adjustedTransitDays,
}; };
}); });
// Apply service level filter if specified
let filteredResults = results; let filteredResults = results;
if (input.filters?.serviceLevels && input.filters.serviceLevels.length > 0) { if (input.filters?.serviceLevels?.length) {
filteredResults = results.filter( filteredResults = results.filter(
r => r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel) r => r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel)
); );
} }
// Sort by total price (ascending) - ECONOMIC first, then STANDARD, then RAPID filteredResults.sort(
filteredResults.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice); (a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting
);
return { return {
results: filteredResults, results: filteredResults,
@ -229,197 +156,110 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
async getAvailableCompanies(): Promise<string[]> { async getAvailableCompanies(): Promise<string[]> {
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
const companies = new Set(allRates.map(rate => rate.companyName)); return [...new Set(allRates.map(r => r.companyName))].sort();
return Array.from(companies).sort();
} }
async getAvailableContainerTypes(): Promise<string[]> { async getAvailableContainerTypes(): Promise<string[]> {
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
const types = new Set(allRates.map(rate => rate.containerType.getValue())); return [...new Set(allRates.map(r => r.containerType.getValue()))].sort();
return Array.from(types).sort();
} }
/**
* Get all unique origin port codes from CSV rates
* Used to limit port selection to only those with available routes
*/
async getAvailableOrigins(): Promise<string[]> { async getAvailableOrigins(): Promise<string[]> {
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
const origins = new Set(allRates.map(rate => rate.origin.getValue())); return [...new Set(allRates.map(r => r.originCode.getValue()))].sort();
return Array.from(origins).sort();
} }
/**
* Get all destination port codes available for a given origin
* Used to limit destination selection based on selected origin
*/
async getAvailableDestinations(origin: string): Promise<string[]> { async getAvailableDestinations(origin: string): Promise<string[]> {
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
const originCode = PortCode.create(origin); const originCode = PortCode.create(origin);
return [
const destinations = new Set( ...new Set(
allRates allRates.filter(r => r.originCode.equals(originCode)).map(r => r.destinationCode.getValue())
.filter(rate => rate.origin.equals(originCode)) ),
.map(rate => rate.destination.getValue()) ].sort();
);
return Array.from(destinations).sort();
} }
/**
* Get all available routes (origin-destination pairs) from CSV rates
* Returns a map of origin codes to their available destination codes
*/
async getAvailableRoutes(): Promise<Map<string, string[]>> { async getAvailableRoutes(): Promise<Map<string, string[]>> {
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
const routeMap = new Map<string, Set<string>>(); const routeMap = new Map<string, Set<string>>();
allRates.forEach(rate => { allRates.forEach(rate => {
const origin = rate.origin.getValue(); const origin = rate.originCode.getValue();
const destination = rate.destination.getValue(); const destination = rate.destinationCode.getValue();
if (!routeMap.has(origin)) routeMap.set(origin, new Set());
if (!routeMap.has(origin)) {
routeMap.set(origin, new Set());
}
routeMap.get(origin)!.add(destination); routeMap.get(origin)!.add(destination);
}); });
// Convert Sets to sorted arrays
const result = new Map<string, string[]>(); const result = new Map<string, string[]>();
routeMap.forEach((destinations, origin) => { routeMap.forEach((destinations, origin) => {
result.set(origin, Array.from(destinations).sort()); result.set(origin, [...destinations].sort());
}); });
return result; return result;
} }
/**
* Load all rates from all CSV files
*/
private async loadAllRates(): Promise<CsvRate[]> { private async loadAllRates(): Promise<CsvRate[]> {
// If config repository is available, load rates with emails and company names from configs
if (this.configRepository) { if (this.configRepository) {
const configs = await this.configRepository.findActiveConfigs(); const configs = await this.configRepository.findActiveConfigs();
const ratePromises = configs.map(config => {
if (configs.length > 0) {
const results = await Promise.allSettled(
configs.map(config => {
const email = config.metadata?.companyEmail || 'bookings@example.com'; const email = config.metadata?.companyEmail || 'bookings@example.com';
// Pass company name from config to override CSV column value return this.csvRateLoader.loadRatesFromCsv(
return this.csvRateLoader.loadRatesFromCsv(config.csvFilePath, email, config.companyName); config.csvFilePath,
}); email,
config.companyName
);
})
);
// Use allSettled to handle missing files gracefully const failures = results.filter(r => r.status === 'rejected');
const results = await Promise.allSettled(ratePromises);
const rateArrays = results
.filter(
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
)
.map(result => result.value);
// Log any failed file loads
const failures = results.filter(result => result.status === 'rejected');
if (failures.length > 0) { if (failures.length > 0) {
console.warn( console.warn(`Failed to load ${failures.length} CSV files from database configs`);
`Failed to load ${failures.length} CSV files:`,
failures.map(
(f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`
)
);
} }
return rateArrays.flat(); return results
.filter((r): r is PromiseFulfilledResult<CsvRate[]> => r.status === 'fulfilled')
.flatMap(r => r.value);
}
// DB has no active configs — fall through to local CSV files
console.warn('No active CSV rate configs in database, loading from local CSV files');
} }
// Fallback: load files without email (use default)
const files = await this.csvRateLoader.getAvailableCsvFiles(); const files = await this.csvRateLoader.getAvailableCsvFiles();
const ratePromises = files.map(file => const results = await Promise.allSettled(
this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com') files.map(file => this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com'))
); );
// Use allSettled here too for consistency return results
const results = await Promise.allSettled(ratePromises); .filter((r): r is PromiseFulfilledResult<CsvRate[]> => r.status === 'fulfilled')
const rateArrays = results .flatMap(r => r.value);
.filter(
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
)
.map(result => result.value);
return rateArrays.flat();
} }
/**
* Filter rates by route (origin/destination)
*/
private filterByRoute(rates: CsvRate[], origin: PortCode, destination: PortCode): CsvRate[] { private filterByRoute(rates: CsvRate[], origin: PortCode, destination: PortCode): CsvRate[] {
return rates.filter(rate => rate.matchesRoute(origin, destination)); return rates.filter(rate => rate.matchesRoute(origin, destination));
} }
/**
* Filter rates by volume/weight range
*/
private filterByVolume(rates: CsvRate[], volume: Volume): CsvRate[] {
return rates.filter(rate => rate.matchesVolume(volume));
}
/**
* Filter rates by pallet count
*/
private filterByPalletCount(rates: CsvRate[], palletCount: number): CsvRate[] {
return rates.filter(rate => rate.matchesPalletCount(palletCount));
}
/**
* Apply advanced filters to rate list
*/
private applyAdvancedFilters( private applyAdvancedFilters(
rates: CsvRate[], rates: CsvRate[],
filters: RateSearchFilters, filters: RateSearchFilters,
volume: Volume input: CsvRateSearchInput
): CsvRate[] { ): CsvRate[] {
let filtered = rates; let filtered = rates;
// Company filter if (filters.companies?.length) {
if (filters.companies && filters.companies.length > 0) {
filtered = filtered.filter(rate => filters.companies!.includes(rate.companyName)); filtered = filtered.filter(rate => filters.companies!.includes(rate.companyName));
} }
// Volume CBM filter if (filters.onlyDirect) {
if (filters.minVolumeCBM !== undefined) { filtered = filtered.filter(rate => rate.isDirectRoute());
filtered = filtered.filter(rate => rate.volumeRange.maxCBM >= filters.minVolumeCBM!);
}
if (filters.maxVolumeCBM !== undefined) {
filtered = filtered.filter(rate => rate.volumeRange.minCBM <= filters.maxVolumeCBM!);
} }
// Weight KG filter if (filters.excludeNonDgRoutes) {
if (filters.minWeightKG !== undefined) { filtered = filtered.filter(rate => rate.isDgAccepted());
filtered = filtered.filter(rate => rate.weightRange.maxKG >= filters.minWeightKG!);
}
if (filters.maxWeightKG !== undefined) {
filtered = filtered.filter(rate => rate.weightRange.minKG <= filters.maxWeightKG!);
} }
// Pallet count filter
if (filters.palletCount !== undefined) {
filtered = filtered.filter(rate => rate.matchesPalletCount(filters.palletCount!));
}
// Price filter (calculate price first)
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
const currency = filters.currency || 'USD';
filtered = filtered.filter(rate => {
const price = rate.getPriceInCurrency(volume, currency);
const amount = price.getAmount();
if (filters.minPrice !== undefined && amount < filters.minPrice) {
return false;
}
if (filters.maxPrice !== undefined && amount > filters.maxPrice) {
return false;
}
return true;
});
}
// Transit days filter
if (filters.minTransitDays !== undefined) { if (filters.minTransitDays !== undefined) {
filtered = filtered.filter(rate => rate.transitDays >= filters.minTransitDays!); filtered = filtered.filter(rate => rate.transitDays >= filters.minTransitDays!);
} }
@ -427,52 +267,55 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!); filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!);
} }
// Container type filter if (filters.containerTypes?.length) {
if (filters.containerTypes && filters.containerTypes.length > 0) {
filtered = filtered.filter(rate => filtered = filtered.filter(rate =>
filters.containerTypes!.includes(rate.containerType.getValue()) filters.containerTypes!.includes(rate.containerType.getValue())
); );
} }
// All-in prices only filter
if (filters.onlyAllInPrices) {
filtered = filtered.filter(rate => rate.isAllInPrice());
}
// Departure date / validity filter
if (filters.departureDate) { if (filters.departureDate) {
filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!)); filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!));
} }
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
filtered = filtered.filter(rate => {
const bd = this.priceCalculator.calculatePrice(rate, {
volumeCBM: input.volumeCBM,
weightKG: input.weightKG,
hasDangerousGoods: input.hasDangerousGoods ?? false,
});
if (filters.minPrice !== undefined && bd.totalPriceForSorting < filters.minPrice) {
return false;
}
if (filters.maxPrice !== undefined && bd.totalPriceForSorting > filters.maxPrice) {
return false;
}
return true;
});
}
return filtered; return filtered;
} }
/** /**
* Calculate match score (0-100) based on how well rate matches input * Score (0100) based on routing type, departure frequency, and rate validity.
* Higher score = better match * Higher = better match.
*/ */
private calculateMatchScore(rate: CsvRate, input: CsvRateSearchInput): number { private calculateMatchScore(rate: CsvRate): number {
let score = 100; let score = 100;
// Reduce score if volume/weight is near boundaries // Direct route bonus
const volumeUtilization = if (rate.isDirectRoute()) {
(input.volumeCBM - rate.volumeRange.minCBM) / score += 10;
(rate.volumeRange.maxCBM - rate.volumeRange.minCBM); } else {
if (volumeUtilization < 0.2 || volumeUtilization > 0.8) {
score -= 10; // Near boundaries
}
// Reduce score if pallet count doesn't match exactly
if (rate.palletCount !== 0 && input.palletCount !== rate.palletCount) {
score -= 5; score -= 5;
} }
// Increase score for all-in prices (simpler for customers) // Frequency bonus (Weekly = best)
if (rate.isAllInPrice()) { const freqScore = rate.getFrequencyScore(); // 14
score += 5; score += (freqScore - 2) * 5; // Weekly: +10, Bi-Weekly: +5, Bi-Monthly: 0, Monthly: -5
}
// Reduce score for rates expiring soon // Validity penalty
const daysUntilExpiry = Math.floor( const daysUntilExpiry = Math.floor(
(rate.validity.getEndDate().getTime() - Date.now()) / (1000 * 60 * 60 * 24) (rate.validity.getEndDate().getTime() - Date.now()) / (1000 * 60 * 60 * 24)
); );
@ -485,3 +328,7 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
return Math.max(0, Math.min(100, score)); return Math.max(0, Math.min(100, score));
} }
} }
function round2(n: number): number {
return Math.round(n * 100) / 100;
}

View File

@ -2,16 +2,8 @@ import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.
import { CsvRate } from '../entities/csv-rate.entity'; import { CsvRate } from '../entities/csv-rate.entity';
import { PortCode } from '../value-objects/port-code.vo'; import { PortCode } from '../value-objects/port-code.vo';
import { ContainerType } from '../value-objects/container-type.vo'; import { ContainerType } from '../value-objects/container-type.vo';
import { Money } from '../value-objects/money.vo'; import { DateRange } from '../value-objects/date-range.vo';
/**
* Test Suite for Rate Offer Generator Service
*
* Vérifie que:
* - RAPID est le plus cher ET le plus rapide
* - ECONOMIC est le moins cher ET le plus lent
* - STANDARD est au milieu en prix et transit time
*/
describe('RateOfferGeneratorService', () => { describe('RateOfferGeneratorService', () => {
let service: RateOfferGeneratorService; let service: RateOfferGeneratorService;
let mockRate: CsvRate; let mockRate: CsvRate;
@ -19,415 +11,226 @@ describe('RateOfferGeneratorService', () => {
beforeEach(() => { beforeEach(() => {
service = new RateOfferGeneratorService(); service = new RateOfferGeneratorService();
// Créer un tarif de base pour les tests // Mock minimal CsvRate compatible with new schema
// Prix: 1000 USD / 900 EUR, Transit: 20 jours
mockRate = { mockRate = {
companyName: 'Test Carrier', companyName: 'Test Carrier',
companyEmail: 'test@carrier.com', companyEmail: 'test@carrier.com',
origin: PortCode.create('FRPAR'), originCFS: 'Fos Sur Mer',
destination: PortCode.create('USNYC'), originCode: PortCode.create('FRFOS'),
portOfLoading: 'FOS SUR MER',
routing: 'Direct',
destinationCFS: 'New York',
destinationCode: PortCode.create('USNYC'),
destinationCountry: 'USA',
containerType: ContainerType.create('LCL'), containerType: ContainerType.create('LCL'),
volumeRange: { minCBM: 1, maxCBM: 10 }, freight: {
weightRange: { minKG: 100, maxKG: 5000 }, freightCurrency: 'USD',
palletCount: 0, freightRatePerCBM: 50,
pricing: { freightMinimum: 500,
pricePerCBM: 100,
pricePerKG: 0.5,
basePriceUSD: Money.create(1000, 'USD'),
basePriceEUR: Money.create(900, 'EUR'),
}, },
currency: 'USD', fob: {
hasSurcharges: false, fobCurrency: 'EUR',
surchargeBAF: null, fobDocumentation: 55,
surchargeCAF: null, fobISPS: 18,
surchargeDetails: null, fobHandling: 22,
fobHandlingUnit: 'W',
fobHandlingMinimum: 110,
fobSolas: 15,
fobCustoms: 85,
fobAMS_ACI: 35,
fobISF5: 0,
fobDGAdmin: 50,
},
dgSurcharge: {
dgSurchargeCurrency: 'EUR',
dgSurchargeRate: 20,
dgSurchargeUnit: 'UP',
dgSurchargeMin: 50,
},
remarks: '',
frequency: 'Weekly',
transitDays: 20, transitDays: 20,
validity: { validity: DateRange.create(new Date('2026-01-01'), new Date('2026-12-31'), true),
getStartDate: () => new Date('2024-01-01'),
getEndDate: () => new Date('2024-12-31'),
},
isValidForDate: () => true, isValidForDate: () => true,
isCurrentlyValid: () => true,
matchesRoute: () => true, matchesRoute: () => true,
matchesVolume: () => true, isDgAccepted: () => true,
matchesPalletCount: () => true, isDgOnRequest: () => false,
getPriceInCurrency: () => Money.create(1000, 'USD'), isDirectRoute: () => true,
isAllInPrice: () => true, getFrequencyScore: () => 4,
getSurchargeDetails: () => null, getRouteDescription: () => 'FRFOS → USNYC',
getSummary: () => 'Test Carrier: FRFOS → USNYC',
toString: () => 'Test Carrier: FRFOS → USNYC',
} as any; } as any;
}); });
describe('generateOffers', () => { describe('generateOffers', () => {
it('devrait générer exactement 3 offres (RAPID, STANDARD, ECONOMIC)', () => { it('generates exactly 3 offers (RAPID, STANDARD, ECONOMIC)', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
expect(offers).toHaveLength(3); expect(offers).toHaveLength(3);
expect(offers.map(o => o.serviceLevel)).toEqual( expect(offers.map(o => o.serviceLevel)).toEqual(
expect.arrayContaining([ServiceLevel.RAPID, ServiceLevel.STANDARD, ServiceLevel.ECONOMIC]) expect.arrayContaining([ServiceLevel.RAPID, ServiceLevel.STANDARD, ServiceLevel.ECONOMIC])
); );
}); });
it('ECONOMIC doit être le moins cher', () => { it('ECONOMIC has the lowest price multiplier (0.85)', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC); expect(economic.priceMultiplier).toBe(0.85);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD); expect(economic.priceAdjustmentPercent).toBe(-15);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// ECONOMIC doit avoir le prix le plus bas
expect(economic!.adjustedPriceUSD).toBeLessThan(standard!.adjustedPriceUSD);
expect(economic!.adjustedPriceUSD).toBeLessThan(rapid!.adjustedPriceUSD);
// Vérifier le prix attendu: 1000 * 0.85 = 850 USD
expect(economic!.adjustedPriceUSD).toBe(850);
expect(economic!.priceAdjustmentPercent).toBe(-15);
}); });
it('RAPID doit être le plus cher', () => { it('RAPID has the highest price multiplier (1.2)', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC); expect(rapid.priceMultiplier).toBe(1.2);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD); expect(rapid.priceAdjustmentPercent).toBe(20);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// RAPID doit avoir le prix le plus élevé
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(standard!.adjustedPriceUSD);
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(economic!.adjustedPriceUSD);
// Vérifier le prix attendu: 1000 * 1.20 = 1200 USD
expect(rapid!.adjustedPriceUSD).toBe(1200);
expect(rapid!.priceAdjustmentPercent).toBe(20);
}); });
it("STANDARD doit avoir le prix de base (pas d'ajustement)", () => { it('STANDARD has no price adjustment (multiplier = 1.0)', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD); expect(standard.priceMultiplier).toBe(1.0);
expect(standard.priceAdjustmentPercent).toBe(0);
// STANDARD doit avoir le prix de base (pas de changement)
expect(standard!.adjustedPriceUSD).toBe(1000);
expect(standard!.adjustedPriceEUR).toBe(900);
expect(standard!.priceAdjustmentPercent).toBe(0);
}); });
it('RAPID doit être le plus rapide (moins de jours de transit)', () => { it('RAPID has the shortest transit time', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC); expect(rapid.adjustedTransitDays).toBeLessThan(standard.adjustedTransitDays);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD); expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID); // 20 * 0.70 = 14
expect(rapid.adjustedTransitDays).toBe(14);
// RAPID doit avoir le transit time le plus court
expect(rapid!.adjustedTransitDays).toBeLessThan(standard!.adjustedTransitDays);
expect(rapid!.adjustedTransitDays).toBeLessThan(economic!.adjustedTransitDays);
// Vérifier le transit attendu: 20 * 0.70 = 14 jours
expect(rapid!.adjustedTransitDays).toBe(14);
expect(rapid!.transitAdjustmentPercent).toBe(-30);
}); });
it('ECONOMIC doit être le plus lent (plus de jours de transit)', () => { it('ECONOMIC has the longest transit time', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC); expect(economic.adjustedTransitDays).toBeGreaterThan(standard.adjustedTransitDays);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD); // 20 * 1.50 = 30
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID); expect(economic.adjustedTransitDays).toBe(30);
// ECONOMIC doit avoir le transit time le plus long
expect(economic!.adjustedTransitDays).toBeGreaterThan(standard!.adjustedTransitDays);
expect(economic!.adjustedTransitDays).toBeGreaterThan(rapid!.adjustedTransitDays);
// Vérifier le transit attendu: 20 * 1.50 = 30 jours
expect(economic!.adjustedTransitDays).toBe(30);
expect(economic!.transitAdjustmentPercent).toBe(50);
}); });
it("STANDARD doit avoir le transit time de base (pas d'ajustement)", () => { it('STANDARD has no transit adjustment', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD); expect(standard.adjustedTransitDays).toBe(20);
expect(standard.transitAdjustmentPercent).toBe(0);
// STANDARD doit avoir le transit time de base
expect(standard!.adjustedTransitDays).toBe(20);
expect(standard!.transitAdjustmentPercent).toBe(0);
}); });
it('les offres doivent être triées par prix croissant (ECONOMIC -> STANDARD -> RAPID)', () => { it('offers are sorted by priceMultiplier (ECONOMIC → STANDARD → RAPID)', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC); expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
expect(offers[1].serviceLevel).toBe(ServiceLevel.STANDARD); expect(offers[1].serviceLevel).toBe(ServiceLevel.STANDARD);
expect(offers[2].serviceLevel).toBe(ServiceLevel.RAPID); expect(offers[2].serviceLevel).toBe(ServiceLevel.RAPID);
// Vérifier que les prix sont dans l'ordre croissant
expect(offers[0].adjustedPriceUSD).toBeLessThan(offers[1].adjustedPriceUSD);
expect(offers[1].adjustedPriceUSD).toBeLessThan(offers[2].adjustedPriceUSD);
}); });
it('doit conserver les informations originales du tarif', () => { it('clamps transit time to minimum (5 days)', () => {
const offers = service.generateOffers(mockRate); const shortTransitRate = { ...mockRate, transitDays: 3 } as any;
const offers = service.generateOffers(shortTransitRate);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.adjustedTransitDays).toBe(5);
});
it('clamps transit time to maximum (90 days)', () => {
const longTransitRate = { ...mockRate, transitDays: 80 } as any;
const offers = service.generateOffers(longTransitRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
expect(economic.adjustedTransitDays).toBe(90);
});
it('preserves the original rate reference', () => {
const offers = service.generateOffers(mockRate);
for (const offer of offers) { for (const offer of offers) {
expect(offer.rate).toBe(mockRate); expect(offer.rate).toBe(mockRate);
expect(offer.originalPriceUSD).toBe(1000);
expect(offer.originalPriceEUR).toBe(900);
expect(offer.originalTransitDays).toBe(20); expect(offer.originalTransitDays).toBe(20);
} }
}); });
it('doit appliquer la contrainte de transit time minimum (5 jours)', () => {
// Tarif avec transit time très court (3 jours)
const shortTransitRate = {
...mockRate,
transitDays: 3,
} as any;
const offers = service.generateOffers(shortTransitRate);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// RAPID avec 3 * 0.70 = 2.1 jours, mais minimum est 5 jours
expect(rapid!.adjustedTransitDays).toBe(5);
});
it('doit appliquer la contrainte de transit time maximum (90 jours)', () => {
// Tarif avec transit time très long (80 jours)
const longTransitRate = {
...mockRate,
transitDays: 80,
} as any;
const offers = service.generateOffers(longTransitRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
// ECONOMIC avec 80 * 1.50 = 120 jours, mais maximum est 90 jours
expect(economic!.adjustedTransitDays).toBe(90);
});
}); });
describe('generateOffersForRates', () => { describe('generateOffersForRates', () => {
it('doit générer 3 offres par tarif', () => { it('generates 3 offers per rate', () => {
const rate1 = mockRate; const rate2 = { ...mockRate, companyName: 'Another Carrier' } as any;
const rate2 = { const offers = service.generateOffersForRates([mockRate, rate2]);
...mockRate, expect(offers).toHaveLength(6);
companyName: 'Another Carrier',
} as any;
const offers = service.generateOffersForRates([rate1, rate2]);
expect(offers).toHaveLength(6); // 2 tarifs * 3 offres
});
it('doit trier toutes les offres par prix croissant', () => {
const rate1 = mockRate; // Prix base: 1000 USD
const rate2 = {
...mockRate,
companyName: 'Cheaper Carrier',
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(500, 'USD'), // Prix base plus bas
},
} as any;
const offers = service.generateOffersForRates([rate1, rate2]);
// Vérifier que les prix sont triés
for (let i = 0; i < offers.length - 1; i++) {
expect(offers[i].adjustedPriceUSD).toBeLessThanOrEqual(offers[i + 1].adjustedPriceUSD);
}
// L'offre la moins chère devrait être ECONOMIC du rate2
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
expect(offers[0].rate.companyName).toBe('Cheaper Carrier');
}); });
}); });
describe('generateOffersForServiceLevel', () => { describe('generateOffersForServiceLevel', () => {
it('doit générer uniquement les offres RAPID', () => { it('generates only RAPID offers', () => {
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.RAPID); const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.RAPID);
expect(offers).toHaveLength(1); expect(offers).toHaveLength(1);
expect(offers[0].serviceLevel).toBe(ServiceLevel.RAPID); expect(offers[0].serviceLevel).toBe(ServiceLevel.RAPID);
}); });
it('doit générer uniquement les offres ECONOMIC', () => { it('generates only ECONOMIC offers', () => {
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.ECONOMIC); const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.ECONOMIC);
expect(offers).toHaveLength(1); expect(offers).toHaveLength(1);
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC); expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
}); });
}); });
describe('getCheapestOffer', () => {
it("doit retourner l'offre ECONOMIC la moins chère", () => {
const rate1 = mockRate; // 1000 USD base
const rate2 = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(500, 'USD'),
},
} as any;
const cheapest = service.getCheapestOffer([rate1, rate2]);
expect(cheapest).not.toBeNull();
expect(cheapest!.serviceLevel).toBe(ServiceLevel.ECONOMIC);
// 500 * 0.85 = 425 USD
expect(cheapest!.adjustedPriceUSD).toBe(425);
});
it('doit retourner null si aucun tarif', () => {
const cheapest = service.getCheapestOffer([]);
expect(cheapest).toBeNull();
});
});
describe('getFastestOffer', () => {
it("doit retourner l'offre RAPID la plus rapide", () => {
const rate1 = { ...mockRate, transitDays: 20 } as any;
const rate2 = { ...mockRate, transitDays: 10 } as any;
const fastest = service.getFastestOffer([rate1, rate2]);
expect(fastest).not.toBeNull();
expect(fastest!.serviceLevel).toBe(ServiceLevel.RAPID);
// 10 * 0.70 = 7 jours
expect(fastest!.adjustedTransitDays).toBe(7);
});
it('doit retourner null si aucun tarif', () => {
const fastest = service.getFastestOffer([]);
expect(fastest).toBeNull();
});
});
describe('getBestOffersPerServiceLevel', () => { describe('getBestOffersPerServiceLevel', () => {
it('doit retourner la meilleure offre de chaque niveau de service', () => { it('returns one offer per service level', () => {
const rate1 = mockRate; const best = service.getBestOffersPerServiceLevel([mockRate]);
const rate2 = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(800, 'USD'),
},
} as any;
const best = service.getBestOffersPerServiceLevel([rate1, rate2]);
expect(best.rapid).not.toBeNull(); expect(best.rapid).not.toBeNull();
expect(best.standard).not.toBeNull(); expect(best.standard).not.toBeNull();
expect(best.economic).not.toBeNull(); expect(best.economic).not.toBeNull();
});
// Toutes doivent provenir du rate2 (moins cher) it('returns null for all levels when no rates', () => {
expect(best.rapid!.originalPriceUSD).toBe(800); const best = service.getBestOffersPerServiceLevel([]);
expect(best.standard!.originalPriceUSD).toBe(800); expect(best.rapid).toBeNull();
expect(best.economic!.originalPriceUSD).toBe(800); expect(best.standard).toBeNull();
expect(best.economic).toBeNull();
}); });
}); });
describe('isRateEligible', () => { describe('isRateEligible', () => {
it('doit accepter un tarif valide', () => { it('accepts a valid rate', () => {
expect(service.isRateEligible(mockRate)).toBe(true); expect(service.isRateEligible(mockRate)).toBe(true);
}); });
it('doit rejeter un tarif avec transit time = 0', () => { it('rejects a rate with transitDays = 0', () => {
const invalidRate = { ...mockRate, transitDays: 0 } as any; const invalid = { ...mockRate, transitDays: 0 } as any;
expect(service.isRateEligible(invalidRate)).toBe(false); expect(service.isRateEligible(invalid)).toBe(false);
}); });
it('doit rejeter un tarif avec prix = 0', () => { it('rejects a rate with freightRatePerCBM = 0 and freightMinimum = 0', () => {
const invalidRate = { const invalid = {
...mockRate, ...mockRate,
pricing: { freight: { ...mockRate.freight, freightRatePerCBM: 0, freightMinimum: 0 },
...mockRate.pricing,
basePriceUSD: Money.create(0, 'USD'),
},
} as any; } as any;
expect(service.isRateEligible(invalidRate)).toBe(false); expect(service.isRateEligible(invalid)).toBe(false);
}); });
it('doit rejeter un tarif expiré', () => { it('rejects an expired rate', () => {
const expiredRate = { const expired = { ...mockRate, isValidForDate: () => false } as any;
...mockRate, expect(service.isRateEligible(expired)).toBe(false);
isValidForDate: () => false,
} as any;
expect(service.isRateEligible(expiredRate)).toBe(false);
}); });
}); });
describe('filterEligibleRates', () => { describe('Business logic invariants', () => {
it('doit filtrer les tarifs invalides', () => { it('RAPID priceMultiplier always > ECONOMIC priceMultiplier', () => {
const validRate = mockRate; const offers = service.generateOffers(mockRate);
const invalidRate1 = { ...mockRate, transitDays: 0 } as any;
const invalidRate2 = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(0, 'USD'),
},
} as any;
const eligibleRates = service.filterEligibleRates([validRate, invalidRate1, invalidRate2]);
expect(eligibleRates).toHaveLength(1);
expect(eligibleRates[0]).toBe(validRate);
});
});
describe('Validation de la logique métier', () => {
it('RAPID doit TOUJOURS être plus cher que ECONOMIC', () => {
// Test avec différents prix de base
const prices = [100, 500, 1000, 5000, 10000];
for (const price of prices) {
const rate = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(price, 'USD'),
},
} as any;
const offers = service.generateOffers(rate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!; const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!; const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.priceMultiplier).toBeGreaterThan(economic.priceMultiplier);
expect(rapid.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
}
}); });
it('RAPID doit TOUJOURS être plus rapide que ECONOMIC', () => { it('RAPID transit always < ECONOMIC transit for different base days', () => {
// Test avec différents transit times de base for (const days of [5, 10, 20, 30, 60]) {
const transitDays = [5, 10, 20, 30, 60];
for (const days of transitDays) {
const rate = { ...mockRate, transitDays: days } as any; const rate = { ...mockRate, transitDays: days } as any;
const offers = service.generateOffers(rate); const offers = service.generateOffers(rate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!; const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!; const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays); expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
} }
}); });
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le prix', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(standard.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
expect(standard.adjustedPriceUSD).toBeLessThan(rapid.adjustedPriceUSD);
});
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le transit time', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(standard.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
expect(standard.adjustedTransitDays).toBeGreaterThan(rapid.adjustedTransitDays);
});
}); });
}); });

View File

@ -3,9 +3,9 @@ import { CsvRate } from '../entities/csv-rate.entity';
/** /**
* Service Level Types * Service Level Types
* *
* - RAPID: Offre la plus chère + la plus rapide (transit time réduit) * - RAPID : +20% price, -30% transit (express, priority)
* - STANDARD: Offre standard (prix et transit time de base) * - STANDARD : base price and transit
* - ECONOMIC: Offre la moins chère + la plus lente (transit time augmenté) * - ECONOMIC : -15% price, +50% transit (cheapest, slowest)
*/ */
export enum ServiceLevel { export enum ServiceLevel {
RAPID = 'RAPID', RAPID = 'RAPID',
@ -13,243 +13,110 @@ export enum ServiceLevel {
ECONOMIC = 'ECONOMIC', ECONOMIC = 'ECONOMIC',
} }
/**
* Rate Offer - Variante d'un tarif avec un niveau de service
*/
export interface RateOffer { export interface RateOffer {
rate: CsvRate; rate: CsvRate;
serviceLevel: ServiceLevel; serviceLevel: ServiceLevel;
adjustedPriceUSD: number; priceMultiplier: number;
adjustedPriceEUR: number;
adjustedTransitDays: number; adjustedTransitDays: number;
originalPriceUSD: number;
originalPriceEUR: number;
originalTransitDays: number; originalTransitDays: number;
priceAdjustmentPercent: number; priceAdjustmentPercent: number;
transitAdjustmentPercent: number; transitAdjustmentPercent: number;
description: string; description: string;
} }
/**
* Configuration pour les ajustements de prix et transit par niveau de service
*/
interface ServiceLevelConfig { interface ServiceLevelConfig {
priceMultiplier: number; // Multiplicateur de prix (1.0 = pas de changement) priceMultiplier: number;
transitMultiplier: number; // Multiplicateur de transit time (1.0 = pas de changement) transitMultiplier: number;
description: string; description: string;
} }
/** /**
* Rate Offer Generator Service * Generates RAPID / STANDARD / ECONOMIC variants for a given CSV rate.
* *
* Service du domaine qui génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV. * Price adjustment is applied to the total calculated price in the search service
* * this service only stores the multiplier and the adjusted transit time.
* Règles métier:
* - RAPID : Prix +20%, Transit -30% (plus cher, plus rapide)
* - STANDARD : Prix +0%, Transit +0% (tarif de base)
* - ECONOMIC : Prix -15%, Transit +50% (moins cher, plus lent)
*
* Pure domain logic - Pas de dépendances framework
*/ */
export class RateOfferGeneratorService { export class RateOfferGeneratorService {
/**
* Configuration par défaut des niveaux de service
* Ces valeurs peuvent être ajustées selon les besoins métier
*/
private readonly SERVICE_LEVEL_CONFIGS: Record<ServiceLevel, ServiceLevelConfig> = { private readonly SERVICE_LEVEL_CONFIGS: Record<ServiceLevel, ServiceLevelConfig> = {
[ServiceLevel.RAPID]: { [ServiceLevel.RAPID]: {
priceMultiplier: 1.2, // +20% du prix de base priceMultiplier: 1.2,
transitMultiplier: 0.7, // -30% du temps de transit (plus rapide) transitMultiplier: 0.7,
description: 'Express - Livraison rapide avec service prioritaire', description: 'Express Livraison rapide avec service prioritaire',
}, },
[ServiceLevel.STANDARD]: { [ServiceLevel.STANDARD]: {
priceMultiplier: 1.0, // Prix de base (pas de changement) priceMultiplier: 1.0,
transitMultiplier: 1.0, // Transit time de base (pas de changement) transitMultiplier: 1.0,
description: 'Standard - Service régulier au meilleur rapport qualité/prix', description: 'Standard Service régulier au meilleur rapport qualité/prix',
}, },
[ServiceLevel.ECONOMIC]: { [ServiceLevel.ECONOMIC]: {
priceMultiplier: 0.85, // -15% du prix de base priceMultiplier: 0.85,
transitMultiplier: 1.5, // +50% du temps de transit (plus lent) transitMultiplier: 1.5,
description: 'Économique - Tarif réduit avec délai étendu', description: 'Économique Tarif réduit avec délai étendu',
}, },
}; };
/**
* Transit time minimum (en jours) pour garantir la cohérence
* Même avec réduction, on ne peut pas descendre en dessous de ce minimum
*/
private readonly MIN_TRANSIT_DAYS = 5; private readonly MIN_TRANSIT_DAYS = 5;
/**
* Transit time maximum (en jours) pour garantir la cohérence
* Même avec augmentation, on ne peut pas dépasser ce maximum
*/
private readonly MAX_TRANSIT_DAYS = 90; private readonly MAX_TRANSIT_DAYS = 90;
/**
* Génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV
*
* @param rate - Le tarif CSV de base
* @returns Tableau de 3 offres triées par prix croissant (ECONOMIC, STANDARD, RAPID)
*/
generateOffers(rate: CsvRate): RateOffer[] { generateOffers(rate: CsvRate): RateOffer[] {
const offers: RateOffer[] = []; const offers: RateOffer[] = [];
// Extraire les prix de base
const basePriceUSD = rate.pricing.basePriceUSD.getAmount();
const basePriceEUR = rate.pricing.basePriceEUR.getAmount();
const baseTransitDays = rate.transitDays;
// Générer les 3 offres
for (const serviceLevel of Object.values(ServiceLevel)) { for (const serviceLevel of Object.values(ServiceLevel)) {
const config = this.SERVICE_LEVEL_CONFIGS[serviceLevel]; const config = this.SERVICE_LEVEL_CONFIGS[serviceLevel];
const rawTransit = rate.transitDays * config.transitMultiplier;
// Calculer les prix ajustés const adjustedTransitDays = this.clampTransit(Math.round(rawTransit));
const adjustedPriceUSD = this.roundPrice(basePriceUSD * config.priceMultiplier);
const adjustedPriceEUR = this.roundPrice(basePriceEUR * config.priceMultiplier);
// Calculer le transit time ajusté (avec contraintes min/max)
const rawTransitDays = baseTransitDays * config.transitMultiplier;
const adjustedTransitDays = this.constrainTransitDays(Math.round(rawTransitDays));
// Calculer les pourcentages d'ajustement
const priceAdjustmentPercent = Math.round((config.priceMultiplier - 1) * 100);
const transitAdjustmentPercent = Math.round((config.transitMultiplier - 1) * 100);
offers.push({ offers.push({
rate, rate,
serviceLevel, serviceLevel,
adjustedPriceUSD, priceMultiplier: config.priceMultiplier,
adjustedPriceEUR,
adjustedTransitDays, adjustedTransitDays,
originalPriceUSD: basePriceUSD, originalTransitDays: rate.transitDays,
originalPriceEUR: basePriceEUR, priceAdjustmentPercent: Math.round((config.priceMultiplier - 1) * 100),
originalTransitDays: baseTransitDays, transitAdjustmentPercent: Math.round((config.transitMultiplier - 1) * 100),
priceAdjustmentPercent,
transitAdjustmentPercent,
description: config.description, description: config.description,
}); });
} }
// Trier par prix croissant: ECONOMIC (moins cher) -> STANDARD -> RAPID (plus cher) // ECONOMIC → STANDARD → RAPID (cheapest first)
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD); return offers.sort((a, b) => a.priceMultiplier - b.priceMultiplier);
} }
/**
* Génère plusieurs offres pour une liste de tarifs
*
* @param rates - Liste de tarifs CSV
* @returns Liste de toutes les offres générées (3 par tarif), triées par prix
*/
generateOffersForRates(rates: CsvRate[]): RateOffer[] { generateOffersForRates(rates: CsvRate[]): RateOffer[] {
const allOffers: RateOffer[] = []; return rates.flatMap(rate => this.generateOffers(rate));
for (const rate of rates) {
const offers = this.generateOffers(rate);
allOffers.push(...offers);
} }
// Trier toutes les offres par prix croissant
return allOffers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
}
/**
* Génère uniquement les offres d'un niveau de service spécifique
*
* @param rates - Liste de tarifs CSV
* @param serviceLevel - Niveau de service souhaité (RAPID, STANDARD, ECONOMIC)
* @returns Liste des offres du niveau de service demandé, triées par prix
*/
generateOffersForServiceLevel(rates: CsvRate[], serviceLevel: ServiceLevel): RateOffer[] { generateOffersForServiceLevel(rates: CsvRate[], serviceLevel: ServiceLevel): RateOffer[] {
const offers: RateOffer[] = []; return rates
.map(rate => this.generateOffers(rate).find(o => o.serviceLevel === serviceLevel)!)
for (const rate of rates) { .filter(Boolean);
const allOffers = this.generateOffers(rate);
const matchingOffer = allOffers.find(o => o.serviceLevel === serviceLevel);
if (matchingOffer) {
offers.push(matchingOffer);
}
} }
// Trier par prix croissant
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
}
/**
* Obtient l'offre la moins chère (ECONOMIC) parmi une liste de tarifs
*/
getCheapestOffer(rates: CsvRate[]): RateOffer | null {
if (rates.length === 0) return null;
const economicOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC);
return economicOffers[0] || null;
}
/**
* Obtient l'offre la plus rapide (RAPID) parmi une liste de tarifs
*/
getFastestOffer(rates: CsvRate[]): RateOffer | null {
if (rates.length === 0) return null;
const rapidOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID);
// Trier par transit time croissant (plus rapide en premier)
rapidOffers.sort((a, b) => a.adjustedTransitDays - b.adjustedTransitDays);
return rapidOffers[0] || null;
}
/**
* Obtient les meilleures offres (meilleur rapport qualité/prix)
* Retourne une offre de chaque niveau de service avec le meilleur prix
*/
getBestOffersPerServiceLevel(rates: CsvRate[]): { getBestOffersPerServiceLevel(rates: CsvRate[]): {
rapid: RateOffer | null; rapid: RateOffer | null;
standard: RateOffer | null; standard: RateOffer | null;
economic: RateOffer | null; economic: RateOffer | null;
} { } {
return { return {
rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] || null, rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] ?? null,
standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] || null, standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] ?? null,
economic: this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC)[0] || null, economic: this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC)[0] ?? null,
}; };
} }
/**
* Arrondit le prix à 2 décimales
*/
private roundPrice(price: number): number {
return Math.round(price * 100) / 100;
}
/**
* Contraint le transit time entre les limites min et max
*/
private constrainTransitDays(days: number): number {
return Math.max(this.MIN_TRANSIT_DAYS, Math.min(this.MAX_TRANSIT_DAYS, days));
}
/**
* Vérifie si un tarif est éligible pour la génération d'offres
*
* Critères:
* - Transit time doit être > 0
* - Prix doit être > 0
* - Tarif doit être valide (non expiré)
*/
isRateEligible(rate: CsvRate): boolean { isRateEligible(rate: CsvRate): boolean {
if (rate.transitDays <= 0) return false; if (rate.transitDays <= 0) return false;
if (rate.pricing.basePriceUSD.getAmount() <= 0) return false; // A rate is usable if it has a freight rate or at least a freight minimum
if (rate.freight.freightRatePerCBM <= 0 && rate.freight.freightMinimum <= 0) return false;
if (!rate.isValidForDate(new Date())) return false; if (!rate.isValidForDate(new Date())) return false;
return true; return true;
} }
/**
* Filtre les tarifs éligibles pour la génération d'offres
*/
filterEligibleRates(rates: CsvRate[]): CsvRate[] { filterEligibleRates(rates: CsvRate[]): CsvRate[] {
return rates.filter(rate => this.isRateEligible(rate)); return rates.filter(rate => this.isRateEligible(rate));
} }
private clampTransit(days: number): number {
return Math.max(this.MIN_TRANSIT_DAYS, Math.min(this.MAX_TRANSIT_DAYS, days));
}
} }

View File

@ -5,41 +5,58 @@ import * as path from 'path';
/** /**
* CSV Converter Service * CSV Converter Service
* *
* Détecte automatiquement le format du CSV et convertit au format attendu * Detects and converts CSV files to the standard 33-column Xpeditis format.
* Supporte: *
* - Format standard Xpeditis * Standard format columns (33):
* - Format "Frais FOB FRET" * companyName, companyEmail, originCFS, originCode, portOfLoading, routing,
* destinationCFS, destinationCode, destinationCountry, containerType,
* freightCurrency, freightRatePerCBM, freightMinimum,
* fobCurrency, fobDocumentation, fobISPS, fobHandling, fobHandlingUnit,
* fobHandlingMinimum, fobSolas, fobCustoms, fobAMS_ACI, fobISF5, fobDGAdmin,
* dgSurchargeCurrency, dgSurchargeRate, dgSurchargeUnit, dgSurchargeMin,
* remarks, frequency, transitDays, validFrom, validUntil
*/ */
@Injectable() @Injectable()
export class CsvConverterService { export class CsvConverterService {
private readonly logger = new Logger(CsvConverterService.name); private readonly logger = new Logger(CsvConverterService.name);
// Headers du format standard attendu
private readonly STANDARD_HEADERS = [ private readonly STANDARD_HEADERS = [
'companyName', 'companyName',
'origin', 'companyEmail',
'destination', 'originCFS',
'originCode',
'portOfLoading',
'routing',
'destinationCFS',
'destinationCode',
'destinationCountry',
'containerType', 'containerType',
'minVolumeCBM', 'freightCurrency',
'maxVolumeCBM', 'freightRatePerCBM',
'minWeightKG', 'freightMinimum',
'maxWeightKG', 'fobCurrency',
'palletCount', 'fobDocumentation',
'pricePerCBM', 'fobISPS',
'pricePerKG', 'fobHandling',
'basePriceUSD', 'fobHandlingUnit',
'basePriceEUR', 'fobHandlingMinimum',
'currency', 'fobSolas',
'hasSurcharges', 'fobCustoms',
'surchargeBAF', 'fobAMS_ACI',
'surchargeCAF', 'fobISF5',
'surchargeDetails', 'fobDGAdmin',
'dgSurchargeCurrency',
'dgSurchargeRate',
'dgSurchargeUnit',
'dgSurchargeMin',
'remarks',
'frequency',
'transitDays', 'transitDays',
'validFrom', 'validFrom',
'validUntil', 'validUntil',
]; ];
// Headers du format "Frais FOB FRET" // Legacy "Frais FOB FRET" format indicators (older Excel exports)
private readonly FOB_FRET_HEADERS = [ private readonly FOB_FRET_HEADERS = [
'Origine UN code', 'Origine UN code',
'Destination UN code', 'Destination UN code',
@ -49,259 +66,32 @@ export class CsvConverterService {
'Transit time', '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'> { async detectFormat(filePath: string): Promise<'STANDARD' | 'FOB_FRET' | 'UNKNOWN'> {
try { try {
const content = await fs.readFile(filePath, 'utf-8'); const content = await fs.readFile(filePath, 'utf-8');
const lines = content.split('\n').filter(line => line.trim()); const lines = content.split('\n').filter(l => l.trim());
if (lines.length === 0) return 'UNKNOWN';
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++) { for (let i = 0; i < Math.min(2, lines.length); i++) {
const headers = this.parseCSVLine(lines[i]); const headers = this.parseCSVLine(lines[i]);
if (this.STANDARD_HEADERS.some(h => headers.includes(h))) return 'STANDARD';
// Vérifier format standard if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) return 'FOB_FRET';
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'; return 'UNKNOWN';
} catch (error) { } catch {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Error detecting CSV format: ${errorMessage}`);
return 'UNKNOWN'; 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( async autoConvert(
inputPath: string, inputPath: string,
companyName: string companyName: string
): Promise<{ convertedPath: string; wasConverted: boolean; rowsConverted?: number }> { ): Promise<{ convertedPath: string; wasConverted: boolean; rowsConverted?: number }> {
const format = await this.detectFormat(inputPath); const format = await this.detectFormat(inputPath);
this.logger.log(`Detected CSV format: ${format} for ${inputPath}`);
this.logger.log(`Detected CSV format: ${format}`);
if (format === 'STANDARD') { if (format === 'STANDARD') {
return { return { convertedPath: inputPath, wasConverted: false };
convertedPath: inputPath,
wasConverted: false,
};
} }
if (format === 'FOB_FRET') { if (format === 'FOB_FRET') {
@ -313,6 +103,134 @@ export class CsvConverterService {
}; };
} }
throw new Error(`Unknown CSV format. Please provide a valid CSV file.`); throw new Error(
'Unknown CSV format. Please provide a file matching the standard 33-column schema.'
);
}
async convertFobFretToStandard(
inputPath: string,
companyName: string
): Promise<{ outputPath: string; rowsConverted: number }> {
this.logger.log(`Converting legacy FOB FRET CSV: ${inputPath}`);
const content = await fs.readFile(inputPath, 'utf-8');
const lines = content.split('\n').filter(l => l.trim());
if (lines.length < 2) throw new Error('CSV file is empty or has no data rows');
// Find the header line
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;
}
}
const headers = this.parseCSVLine(lines[headerLineIndex]);
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, idx) => (row[header] = values[idx] || ''));
if (row['Origine UN code'] && row['Destination UN code']) {
dataRows.push(row);
}
}
const convertedRows = dataRows.map(row => this.convertFobFretRow(row, companyName));
const outputLines: string[] = [this.STANDARD_HEADERS.join(',')];
convertedRows.forEach(row => {
const values = this.STANDARD_HEADERS.map(header => {
const value = row[header] ?? '';
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
});
outputLines.push(values.join(','));
});
const outputPath = path.isAbsolute(inputPath)
? inputPath.replace('.csv', '-converted.csv')
: path.resolve(process.cwd(), inputPath.replace('.csv', '-converted.csv'));
await fs.writeFile(outputPath, outputLines.join('\n'), 'utf-8');
this.logger.log(`Conversion complete: ${outputPath} (${convertedRows.length} rows)`);
return { outputPath, rowsConverted: convertedRows.length };
}
private convertFobFretRow(row: Record<string, string>, companyName: string): Record<string, any> {
const freightCurrency = row['Devise FRET'] || 'USD';
const freightRatePerCBM = parseFloat(row['Taux de FRET (UP)']) || 0;
const freightMinimum = parseFloat(row['Minimum FRET (LS)']) || 0;
const transitDays = parseInt(row['Transit time'], 10) || 0;
const fobCurrency = row['Devise FOB'] || 'EUR';
const validFrom = new Date().toISOString().split('T')[0];
const validUntil = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const originCode = row['Origine UN code'] || '';
const destinationCode = row['Destination UN code'] || '';
return {
companyName,
companyEmail: row['Email'] || '',
originCFS: row['Origine CFS'] || originCode,
originCode,
portOfLoading: row['Port of Loading'] || originCode,
routing: row['Routing'] || 'Direct',
destinationCFS: row['Destination CFS'] || destinationCode,
destinationCode,
destinationCountry: row['Destination Country'] || '',
containerType: 'LCL',
freightCurrency,
freightRatePerCBM,
freightMinimum,
fobCurrency,
fobDocumentation: parseInt(row['Documentation (LS et Minimum)'], 10) || 0,
fobISPS: parseInt(row['ISPS (LS et Minimum)'], 10) || 0,
fobHandling: parseInt(row['Manutention'], 10) || 0,
fobHandlingUnit: row['Unité de manutention (UP;Tonne)'] || 'W',
fobHandlingMinimum: parseInt(row['Minimum manutention'], 10) || 0,
fobSolas: parseInt(row['Solas (LS et Minimum)'], 10) || 0,
fobCustoms: parseInt(row['Douane (LS et Minimum)'], 10) || 0,
fobAMS_ACI: parseFloat(row['AMS/ACI (LS et Minimum)']) || 0,
fobISF5: parseFloat(row['ISF5 (LS et Minimum)']) || 0,
fobDGAdmin: parseInt(row['Frais admin de dangereux (LS et Minimum)'], 10) || 0,
dgSurchargeCurrency: row['Devise surcharge DG'] || fobCurrency,
dgSurchargeRate: row['Taux surcharge DG'] || '0',
dgSurchargeUnit: row['Unité surcharge DG'] || 'LS',
dgSurchargeMin: row['Minimum surcharge DG'] || '0',
remarks: row['Remarques'] || '',
frequency: row['Frequence'] || 'Weekly',
transitDays,
validFrom,
validUntil,
};
}
private parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
} }
} }

View File

@ -4,61 +4,109 @@ import { parse } from 'csv-parse/sync';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port'; import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port';
import { CsvRate } from '@domain/entities/csv-rate.entity'; import {
CsvRate,
FreightPricing,
FobCharges,
DgSurchargeInfo,
DgSurchargeValue,
HandlingUnit,
FrequencyType,
} from '@domain/entities/csv-rate.entity';
import { PortCode } from '@domain/value-objects/port-code.vo'; import { PortCode } from '@domain/value-objects/port-code.vo';
import { ContainerType } from '@domain/value-objects/container-type.vo'; import { ContainerType } from '@domain/value-objects/container-type.vo';
import { Money } from '@domain/value-objects/money.vo';
import { Surcharge, SurchargeType, SurchargeCollection } from '@domain/value-objects/surcharge.vo';
import { DateRange } from '@domain/value-objects/date-range.vo'; import { DateRange } from '@domain/value-objects/date-range.vo';
import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter'; import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter';
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository'; import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
/** /**
* CSV Row Interface * Standardized 33-column CSV row.
* Maps to CSV file structure * All suppliers share this exact schema.
*/ */
interface CsvRow { interface CsvRow {
// Supplier identity
companyName: string; companyName: string;
origin: string; companyEmail: string;
destination: string; // Route geography
originCFS: string;
originCode: string;
portOfLoading: string;
routing: string;
destinationCFS: string;
destinationCode: string;
destinationCountry: string;
// Container
containerType: string; containerType: string;
minVolumeCBM: string; // Freight
maxVolumeCBM: string; freightCurrency: string;
minWeightKG: string; freightRatePerCBM: string;
maxWeightKG: string; freightMinimum: string;
palletCount: string; // FOB charges
pricePerCBM: string; fobCurrency: string;
pricePerKG: string; fobDocumentation: string;
basePriceUSD: string; fobISPS: string;
basePriceEUR: string; fobHandling: string;
currency: string; fobHandlingUnit: string;
hasSurcharges: string; fobHandlingMinimum: string;
surchargeBAF?: string; fobSolas: string;
surchargeCAF?: string; fobCustoms: string;
surchargeDetails?: string; fobAMS_ACI: string;
fobISF5: string;
fobDGAdmin: string;
// DG surcharge
dgSurchargeCurrency: string;
dgSurchargeRate: string;
dgSurchargeUnit: string;
dgSurchargeMin: string;
// Metadata
remarks: string;
frequency: string;
transitDays: string; transitDays: string;
validFrom: string; validFrom: string;
validUntil: string; validUntil: string;
} }
/** const REQUIRED_COLUMNS = [
* CSV Rate Loader Adapter 'companyName',
* 'companyEmail',
* Infrastructure adapter for loading shipping rates from CSV files. 'originCFS',
* Implements CsvRateLoaderPort interface. 'originCode',
* 'portOfLoading',
* Features: 'routing',
* - CSV parsing with validation 'destinationCFS',
* - Mapping CSV rows to domain entities 'destinationCode',
* - Error handling and logging 'destinationCountry',
* - File system operations 'containerType',
*/ 'freightCurrency',
'freightRatePerCBM',
'freightMinimum',
'fobCurrency',
'fobDocumentation',
'fobISPS',
'fobHandling',
'fobHandlingUnit',
'fobHandlingMinimum',
'fobSolas',
'fobCustoms',
'fobAMS_ACI',
'fobISF5',
'fobDGAdmin',
'dgSurchargeCurrency',
'dgSurchargeRate',
'dgSurchargeUnit',
'dgSurchargeMin',
'remarks',
'frequency',
'transitDays',
'validFrom',
'validUntil',
];
@Injectable() @Injectable()
export class CsvRateLoaderAdapter implements CsvRateLoaderPort { export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
private readonly logger = new Logger(CsvRateLoaderAdapter.name); private readonly logger = new Logger(CsvRateLoaderAdapter.name);
private readonly csvDirectory: string; private readonly csvDirectory: string;
// Company name to CSV file mapping
private readonly companyFileMapping: Map<string, string> = new Map([ private readonly companyFileMapping: Map<string, string> = new Map([
['SSC Consolidation', 'ssc-consolidation.csv'], ['SSC Consolidation', 'ssc-consolidation.csv'],
['ECU Worldwide', 'ecu-worldwide.csv'], ['ECU Worldwide', 'ecu-worldwide.csv'],
@ -71,10 +119,6 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
@Optional() private readonly configService?: ConfigService, @Optional() private readonly configService?: ConfigService,
@Optional() private readonly csvConfigRepository?: TypeOrmCsvRateConfigRepository @Optional() private readonly csvConfigRepository?: TypeOrmCsvRateConfigRepository
) { ) {
// CSV files are stored in infrastructure/storage/csv-storage/rates/
// Use absolute path based on project root (works in both dev and production)
// In production, process.cwd() points to the backend app directory
// In development with nest start --watch, it also points to the backend directory
this.csvDirectory = path.join( this.csvDirectory = path.join(
process.cwd(), process.cwd(),
'src', 'src',
@ -84,10 +128,6 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
'rates' 'rates'
); );
this.logger.log(`CSV directory initialized: ${this.csvDirectory}`); this.logger.log(`CSV directory initialized: ${this.csvDirectory}`);
if (this.s3Storage && this.configService) {
this.logger.log('✅ MinIO/S3 storage support enabled for CSV files');
}
} }
async loadRatesFromCsv( async loadRatesFromCsv(
@ -95,49 +135,32 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
companyEmail: string, companyEmail: string,
companyNameOverride?: string companyNameOverride?: string
): Promise<CsvRate[]> { ): Promise<CsvRate[]> {
this.logger.log( this.logger.log(`Loading rates from CSV: ${filePath}`);
`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`
);
try { try {
let fileContent: string; let fileContent: string;
// Try to load from MinIO first if configured
if (this.s3Storage && this.configService && this.csvConfigRepository && companyNameOverride) { if (this.s3Storage && this.configService && this.csvConfigRepository && companyNameOverride) {
try { try {
const config = await this.csvConfigRepository.findByCompanyName(companyNameOverride); const config = await this.csvConfigRepository.findByCompanyName(companyNameOverride);
const minioObjectKey = config?.metadata?.minioObjectKey as string | undefined; const minioObjectKey = config?.metadata?.minioObjectKey as string | undefined;
if (minioObjectKey) { if (minioObjectKey) {
const bucket = this.configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates'); const bucket = this.configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates');
this.logger.log(`📥 Loading CSV from MinIO: ${bucket}/${minioObjectKey}`);
const buffer = await this.s3Storage.download({ bucket, key: minioObjectKey }); const buffer = await this.s3Storage.download({ bucket, key: minioObjectKey });
fileContent = buffer.toString('utf-8'); fileContent = buffer.toString('utf-8');
this.logger.log(`✅ Successfully loaded CSV from MinIO`);
} else { } else {
// Fallback to local file throw new Error('No MinIO object key');
throw new Error('No MinIO object key found, using local file');
} }
} catch (minioError: any) { } catch (minioError: any) {
this.logger.warn( this.logger.warn(`MinIO unavailable: ${minioError.message}. Using local file.`);
`⚠️ Failed to load from MinIO: ${minioError.message}. Falling back to local file.` const fullPath = this.resolvePath(filePath);
);
// Fallback to local file system
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(this.csvDirectory, filePath);
fileContent = await fs.readFile(fullPath, 'utf-8'); fileContent = await fs.readFile(fullPath, 'utf-8');
} }
} else { } else {
// Read from local file system const fullPath = this.resolvePath(filePath);
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(this.csvDirectory, filePath);
fileContent = await fs.readFile(fullPath, 'utf-8'); fileContent = await fs.readFile(fullPath, 'utf-8');
} }
// Parse CSV
const records: CsvRow[] = parse(fileContent, { const records: CsvRow[] = parse(fileContent, {
columns: true, columns: true,
skip_empty_lines: true, skip_empty_lines: true,
@ -145,62 +168,48 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
}); });
this.logger.log(`Parsed ${records.length} rows from ${filePath}`); this.logger.log(`Parsed ${records.length} rows from ${filePath}`);
// Validate structure
this.validateCsvStructure(records); this.validateCsvStructure(records);
// Map to domain entities
const rates = records.map((record, index) => { const rates = records.map((record, index) => {
try { try {
return this.mapToCsvRate(record, companyEmail, companyNameOverride); return this.mapToCsvRate(record, companyEmail, companyNameOverride);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const msg = error instanceof Error ? error.message : String(error);
this.logger.error(`Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`); throw new Error(`Row ${index + 1} in ${filePath}: ${msg}`);
throw new Error(`Invalid data in row ${index + 1} of ${filePath}: ${errorMessage}`);
} }
}); });
this.logger.log(`Successfully loaded ${rates.length} rates from ${filePath}`); this.logger.log(`Loaded ${rates.length} rates from ${filePath}`);
return rates; return rates;
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const msg = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to load CSV file ${filePath}: ${errorMessage}`); this.logger.error(`Failed to load ${filePath}: ${msg}`);
throw new Error(`CSV loading failed for ${filePath}: ${errorMessage}`); throw new Error(`CSV loading failed for ${filePath}: ${msg}`);
} }
} }
async loadRatesByCompany(companyName: string): Promise<CsvRate[]> { async loadRatesByCompany(companyName: string): Promise<CsvRate[]> {
const fileName = this.companyFileMapping.get(companyName); const fileName = this.companyFileMapping.get(companyName);
if (!fileName) { if (!fileName) {
this.logger.warn(`No CSV file configured for company: ${companyName}`); this.logger.warn(`No CSV file for company: ${companyName}`);
return []; return [];
} }
const email = `info@${companyName.toLowerCase().replace(/\s+/g, '-')}.com`;
// Use placeholder email since we don't have access to config repository here return this.loadRatesFromCsv(fileName, email);
const placeholderEmail = `info@${companyName.toLowerCase().replace(/\s+/g, '-')}.com`;
return this.loadRatesFromCsv(fileName, placeholderEmail);
} }
async validateCsvFile( async validateCsvFile(
filePath: string filePath: string
): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> { ): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> {
const errors: string[] = []; const errors: string[] = [];
try { try {
const fullPath = path.isAbsolute(filePath) const fullPath = this.resolvePath(filePath);
? filePath
: path.join(this.csvDirectory, filePath);
// Check if file exists
try { try {
await fs.access(fullPath); await fs.access(fullPath);
} catch { } catch {
errors.push(`File not found: ${filePath}`); return { valid: false, errors: [`File not found: ${filePath}`] };
return { valid: false, errors };
} }
// Read and parse
const fileContent = await fs.readFile(fullPath, 'utf-8'); const fileContent = await fs.readFile(fullPath, 'utf-8');
const records: CsvRow[] = parse(fileContent, { const records: CsvRow[] = parse(fileContent, {
columns: true, columns: true,
@ -209,200 +218,154 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
}); });
if (records.length === 0) { if (records.length === 0) {
errors.push('CSV file is empty'); return { valid: false, errors: ['CSV file is empty'], rowCount: 0 };
return { valid: false, errors, rowCount: 0 };
} }
// Validate structure
try { try {
this.validateCsvStructure(records); this.validateCsvStructure(records);
} catch (error) { } catch (e) {
const errorMessage = error instanceof Error ? error.message : String(error); errors.push(e instanceof Error ? e.message : String(e));
errors.push(errorMessage);
} }
// Validate each row (use dummy email for validation)
records.forEach((record, index) => { records.forEach((record, index) => {
try { try {
this.mapToCsvRate(record, 'validation@example.com'); this.mapToCsvRate(record, 'validation@example.com');
} catch (error) { } catch (e) {
const errorMessage = error instanceof Error ? error.message : String(error); errors.push(`Row ${index + 1}: ${e instanceof Error ? e.message : String(e)}`);
errors.push(`Row ${index + 1}: ${errorMessage}`);
} }
}); });
return { valid: errors.length === 0, errors, rowCount: records.length };
} catch (e) {
return { return {
valid: errors.length === 0, valid: false,
errors, errors: [`Validation failed: ${e instanceof Error ? e.message : String(e)}`],
rowCount: records.length,
}; };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(`Validation failed: ${errorMessage}`);
return { valid: false, errors };
} }
} }
async getAvailableCsvFiles(): Promise<string[]> { async getAvailableCsvFiles(): Promise<string[]> {
try { try {
// If MinIO/S3 is configured, list files from there if (this.s3Storage && this.csvConfigRepository) {
if (this.s3Storage && this.configService && this.csvConfigRepository) {
try { try {
const configs = await this.csvConfigRepository.findAll(); const configs = await this.csvConfigRepository.findAll();
const minioFiles = configs const minioFiles = configs
.filter(config => config.metadata?.minioObjectKey) .filter(c => c.metadata?.minioObjectKey)
.map(config => config.metadata?.minioObjectKey as string); .map(c => c.metadata?.minioObjectKey as string);
if (minioFiles.length > 0) return minioFiles;
if (minioFiles.length > 0) { } catch {
this.logger.log(`📂 Found ${minioFiles.length} CSV files in MinIO`); // fall through to local
return minioFiles;
} else {
this.logger.warn('⚠️ No CSV files configured in MinIO, falling back to local files');
}
} catch (minioError: any) {
this.logger.warn(
`⚠️ Failed to list MinIO files: ${minioError.message}. Falling back to local files.`
);
} }
} }
// Fallback: list from local file system
try { try {
await fs.access(this.csvDirectory); await fs.access(this.csvDirectory);
} catch { } catch {
this.logger.warn(`CSV directory does not exist: ${this.csvDirectory}`);
return []; return [];
} }
const files = await fs.readdir(this.csvDirectory); const files = await fs.readdir(this.csvDirectory);
return files.filter(file => file.endsWith('.csv')); return files.filter(f => f.endsWith('.csv'));
} catch (error) { } catch {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to list CSV files: ${errorMessage}`);
return []; return [];
} }
} }
/** private resolvePath(filePath: string): string {
* Validate that CSV has all required columns return path.isAbsolute(filePath) ? filePath : path.join(this.csvDirectory, filePath);
*/ }
private validateCsvStructure(records: CsvRow[]): void { private validateCsvStructure(records: CsvRow[]): void {
const requiredColumns = [ if (records.length === 0) throw new Error('CSV file is empty');
'companyName',
'origin',
'destination',
'containerType',
'minVolumeCBM',
'maxVolumeCBM',
'minWeightKG',
'maxWeightKG',
'palletCount',
'pricePerCBM',
'pricePerKG',
'basePriceUSD',
'basePriceEUR',
'currency',
'hasSurcharges',
'transitDays',
'validFrom',
'validUntil',
];
if (records.length === 0) {
throw new Error('CSV file is empty');
}
const firstRecord = records[0]; const firstRecord = records[0];
const missingColumns = requiredColumns.filter(col => !(col in firstRecord)); const missing = REQUIRED_COLUMNS.filter(col => !(col in firstRecord));
if (missing.length > 0) {
if (missingColumns.length > 0) { throw new Error(`Missing required columns: ${missing.join(', ')}`);
throw new Error(`Missing required columns: ${missingColumns.join(', ')}`);
} }
} }
/** private mapToCsvRate(r: CsvRow, companyEmail: string, companyNameOverride?: string): CsvRate {
* Map CSV row to CsvRate domain entity const companyName = companyNameOverride || r.companyName.trim();
*/ // Admin-configured email always takes priority over the value in the CSV row
private mapToCsvRate( const email = companyEmail?.trim() || r.companyEmail?.trim();
record: CsvRow,
companyEmail: string,
companyNameOverride?: string
): CsvRate {
// Parse surcharges
const surcharges = this.parseSurcharges(record);
// Create DateRange const freight: FreightPricing = {
const validFrom = new Date(record.validFrom); freightCurrency: r.freightCurrency.toUpperCase(),
const validUntil = new Date(record.validUntil); freightRatePerCBM: parseFloat(r.freightRatePerCBM) || 0,
freightMinimum: parseFloat(r.freightMinimum) || 0,
};
const fob: FobCharges = {
fobCurrency: r.fobCurrency.toUpperCase(),
fobDocumentation: parseInt(r.fobDocumentation, 10) || 0,
fobISPS: parseInt(r.fobISPS, 10) || 0,
fobHandling: parseInt(r.fobHandling, 10) || 0,
fobHandlingUnit: (r.fobHandlingUnit?.toUpperCase() === 'W' ? 'W' : 'UP') as HandlingUnit,
fobHandlingMinimum: parseInt(r.fobHandlingMinimum, 10) || 0,
fobSolas: parseInt(r.fobSolas, 10) || 0,
fobCustoms: parseInt(r.fobCustoms, 10) || 0,
fobAMS_ACI: parseFloat(r.fobAMS_ACI) || 0,
fobISF5: parseFloat(r.fobISF5) || 0,
fobDGAdmin: parseInt(r.fobDGAdmin, 10) || 0,
};
const dgSurcharge: DgSurchargeInfo = {
dgSurchargeCurrency: (r.dgSurchargeCurrency || r.fobCurrency).toUpperCase(),
dgSurchargeRate: parseDgValue(r.dgSurchargeRate),
dgSurchargeUnit: (['UP', 'LS', '%'].includes(r.dgSurchargeUnit?.toUpperCase())
? r.dgSurchargeUnit.toUpperCase()
: 'LS') as 'UP' | 'LS' | '%',
dgSurchargeMin: parseDgValue(r.dgSurchargeMin),
};
const validFrom = new Date(r.validFrom);
const validUntil = new Date(r.validUntil);
const validity = DateRange.create(validFrom, validUntil, true); const validity = DateRange.create(validFrom, validUntil, true);
// Use override company name if provided, otherwise use the one from CSV const frequency = parseFrequency(r.frequency);
const companyName = companyNameOverride || record.companyName.trim();
// Create CsvRate
return new CsvRate( return new CsvRate(
companyName, companyName,
companyEmail, email,
PortCode.create(record.origin), r.originCFS.trim(),
PortCode.create(record.destination), PortCode.create(r.originCode.trim()),
ContainerType.create(record.containerType), r.portOfLoading.trim(),
{ r.routing.trim(),
minCBM: parseFloat(record.minVolumeCBM), r.destinationCFS.trim(),
maxCBM: parseFloat(record.maxVolumeCBM), PortCode.create(r.destinationCode.trim()),
}, r.destinationCountry.trim(),
{ ContainerType.create(r.containerType.trim()),
minKG: parseFloat(record.minWeightKG), freight,
maxKG: parseFloat(record.maxWeightKG), fob,
}, dgSurcharge,
parseInt(record.palletCount, 10), r.remarks?.trim() || '',
{ frequency,
pricePerCBM: parseFloat(record.pricePerCBM), parseInt(r.transitDays, 10),
pricePerKG: parseFloat(record.pricePerKG),
basePriceUSD: Money.create(parseFloat(record.basePriceUSD), 'USD'),
basePriceEUR: Money.create(parseFloat(record.basePriceEUR), 'EUR'),
},
record.currency.toUpperCase(),
new SurchargeCollection(surcharges),
parseInt(record.transitDays, 10),
validity validity
); );
} }
/**
* Parse surcharges from CSV row
*/
private parseSurcharges(record: CsvRow): Surcharge[] {
const hasSurcharges = record.hasSurcharges.toLowerCase() === 'true';
if (!hasSurcharges) {
return [];
} }
const surcharges: Surcharge[] = []; function parseDgValue(raw: string): DgSurchargeValue {
const currency = record.currency.toUpperCase(); if (!raw || raw.trim() === '') return 0;
const upper = raw.trim().toUpperCase();
// BAF (Bunker Adjustment Factor) if (upper === 'ON REQUEST') return 'ON REQUEST';
if (record.surchargeBAF && parseFloat(record.surchargeBAF) > 0) { if (upper === 'NOT ACCEPTED') return 'NOT ACCEPTED';
surcharges.push( const num = parseFloat(raw);
new Surcharge( return isNaN(num) ? 0 : num;
SurchargeType.BAF,
Money.create(parseFloat(record.surchargeBAF), currency),
'Bunker Adjustment Factor'
)
);
} }
// CAF (Currency Adjustment Factor) function parseFrequency(raw: string): FrequencyType {
if (record.surchargeCAF && parseFloat(record.surchargeCAF) > 0) { switch (raw?.trim()) {
surcharges.push( case 'Weekly':
new Surcharge( return 'Weekly';
SurchargeType.CAF, case 'Bi-Weekly':
Money.create(parseFloat(record.surchargeCAF), currency), return 'Bi-Weekly';
'Currency Adjustment Factor' case 'Bi-Monthly':
) return 'Bi-Monthly';
); case 'Monthly':
} return 'Monthly';
default:
return surcharges; return 'Weekly';
} }
} }

View File

@ -21,9 +21,12 @@ interface BookingForm {
volumeCBM: number; volumeCBM: number;
weightKG: number; weightKG: number;
palletCount: number; palletCount: number;
priceUSD: number; freightTotal: number;
priceEUR: number; freightCurrency: string;
primaryCurrency: 'USD' | 'EUR'; fobTotal: number;
fobCurrency: string;
primaryCurrency: string;
totalPriceForSorting: number;
transitDays: number; transitDays: number;
containerType: string; containerType: string;
@ -61,9 +64,12 @@ function NewBookingPageContent() {
volumeCBM: 0, volumeCBM: 0,
weightKG: 0, weightKG: 0,
palletCount: 0, palletCount: 0,
priceUSD: 0, freightTotal: 0,
priceEUR: 0, freightCurrency: 'USD',
primaryCurrency: 'EUR', fobTotal: 0,
fobCurrency: 'EUR',
primaryCurrency: 'USD',
totalPriceForSorting: 0,
transitDays: 0, transitDays: 0,
containerType: '', containerType: '',
documents: [], documents: [],
@ -85,9 +91,12 @@ function NewBookingPageContent() {
volumeCBM: parseFloat(searchParams.get('volumeCBM') || '0'), volumeCBM: parseFloat(searchParams.get('volumeCBM') || '0'),
weightKG: parseFloat(searchParams.get('weightKG') || '0'), weightKG: parseFloat(searchParams.get('weightKG') || '0'),
palletCount: parseInt(searchParams.get('palletCount') || '0'), palletCount: parseInt(searchParams.get('palletCount') || '0'),
priceUSD: rateData.priceUSD, freightTotal: rateData.priceBreakdown.totalFreight,
priceEUR: rateData.priceEUR, freightCurrency: rateData.priceBreakdown.freightCurrency,
primaryCurrency: rateData.primaryCurrency as 'USD' | 'EUR', fobTotal: rateData.priceBreakdown.totalFob,
fobCurrency: rateData.priceBreakdown.fobCurrency,
primaryCurrency: rateData.priceBreakdown.primaryCurrency || 'USD',
totalPriceForSorting: rateData.priceBreakdown.totalPriceForSorting,
transitDays: rateData.transitDays, transitDays: rateData.transitDays,
containerType: rateData.containerType, containerType: rateData.containerType,
})); }));
@ -151,6 +160,14 @@ function NewBookingPageContent() {
// Create FormData for multipart upload // Create FormData for multipart upload
const formDataToSend = new FormData(); const formDataToSend = new FormData();
// Map price breakdown to backend-expected fields — sum all charges per currency
const priceUSD =
(formData.freightCurrency === 'USD' ? formData.freightTotal : 0) +
(formData.fobCurrency === 'USD' ? formData.fobTotal : 0);
const priceEUR =
(formData.freightCurrency === 'EUR' ? formData.freightTotal : 0) +
(formData.fobCurrency === 'EUR' ? formData.fobTotal : 0);
// Append all booking data // Append all booking data
formDataToSend.append('carrierName', formData.carrierName); formDataToSend.append('carrierName', formData.carrierName);
formDataToSend.append('carrierEmail', formData.carrierEmail); formDataToSend.append('carrierEmail', formData.carrierEmail);
@ -159,8 +176,8 @@ function NewBookingPageContent() {
formDataToSend.append('volumeCBM', formData.volumeCBM.toString()); formDataToSend.append('volumeCBM', formData.volumeCBM.toString());
formDataToSend.append('weightKG', formData.weightKG.toString()); formDataToSend.append('weightKG', formData.weightKG.toString());
formDataToSend.append('palletCount', formData.palletCount.toString()); formDataToSend.append('palletCount', formData.palletCount.toString());
formDataToSend.append('priceUSD', formData.priceUSD.toString()); formDataToSend.append('priceUSD', priceUSD.toString());
formDataToSend.append('priceEUR', formData.priceEUR.toString()); formDataToSend.append('priceEUR', priceEUR.toString());
formDataToSend.append('primaryCurrency', formData.primaryCurrency); formDataToSend.append('primaryCurrency', formData.primaryCurrency);
formDataToSend.append('transitDays', formData.transitDays.toString()); formDataToSend.append('transitDays', formData.transitDays.toString());
formDataToSend.append('containerType', formData.containerType); formDataToSend.append('containerType', formData.containerType);
@ -346,22 +363,28 @@ function NewBookingPageContent() {
<h3 className="text-lg font-semibold text-gray-900 mb-4"> <h3 className="text-lg font-semibold text-gray-900 mb-4">
Prix estimé Prix estimé
</h3> </h3>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between mb-4">
<div> <div>
<p className="text-sm text-gray-600">Prix en EUR</p> <p className="text-sm text-gray-600">Fret ({formData.freightCurrency})</p>
<p className="text-3xl font-bold text-green-600"> <p className="text-2xl font-bold text-gray-800">
{formatPrice(formData.priceEUR, 'EUR')} {formatPrice(formData.freightTotal, formData.freightCurrency)}
</p> </p>
</div> </div>
{formData.priceUSD > 0 && ( {formData.fobTotal > 0 && (
<div className="text-right"> <div className="text-right">
<p className="text-sm text-gray-600">Prix en USD</p> <p className="text-sm text-gray-600">FOB ({formData.fobCurrency})</p>
<p className="text-xl font-semibold text-gray-700"> <p className="text-xl font-semibold text-gray-700">
{formatPrice(formData.priceUSD, 'USD')} {formatPrice(formData.fobTotal, formData.fobCurrency)}
</p> </p>
</div> </div>
)} )}
</div> </div>
<div className="border-t border-green-200 pt-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-700">Prix total</p>
<p className="text-3xl font-bold text-green-600">
{formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')}
</p>
</div>
</div> </div>
</div> </div>
@ -562,10 +585,24 @@ function NewBookingPageContent() {
{formData.documents.length} fichier(s) {formData.documents.length} fichier(s)
</span> </span>
</div> </div>
<div className="flex justify-between">
<span className="text-gray-600">Fret :</span>
<span className="font-semibold text-gray-900">
{formatPrice(formData.freightTotal, formData.freightCurrency)}
</span>
</div>
{formData.fobTotal > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">FOB :</span>
<span className="font-semibold text-gray-900">
{formatPrice(formData.fobTotal, formData.fobCurrency)}
</span>
</div>
)}
<div className="flex justify-between border-t pt-3 mt-3"> <div className="flex justify-between border-t pt-3 mt-3">
<span className="text-gray-900 font-semibold">Prix total :</span> <span className="text-gray-900 font-semibold">Prix total :</span>
<span className="text-2xl font-bold text-green-600"> <span className="text-2xl font-bold text-green-600">
{formatPrice(formData.priceEUR, 'EUR')} {formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')}
</span> </span>
</div> </div>
</div> </div>

View File

@ -40,14 +40,7 @@ export default function SearchResultsPage() {
destination, destination,
volumeCBM, volumeCBM,
weightKG, weightKG,
palletCount,
hasDangerousGoods: searchParams.get('hasDangerousGoods') === 'true', hasDangerousGoods: searchParams.get('hasDangerousGoods') === 'true',
requiresSpecialHandling: searchParams.get('requiresSpecialHandling') === 'true',
requiresTailgate: searchParams.get('requiresTailgate') === 'true',
requiresStraps: searchParams.get('requiresStraps') === 'true',
requiresThermalCover: searchParams.get('requiresThermalCover') === 'true',
hasRegulatedProducts: searchParams.get('hasRegulatedProducts') === 'true',
requiresAppointment: searchParams.get('requiresAppointment') === 'true',
}); });
setResults(response.results); setResults(response.results);
@ -83,8 +76,8 @@ export default function SearchResultsPage() {
}; };
} }
const sorted = [...results].sort((a, b) => a.priceEUR - b.priceEUR); const sorted = [...results].sort((a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting);
const fastest = [...results].sort((a, b) => a.transitDays - b.transitDays); const fastest = [...results].sort((a, b) => (a.adjustedTransitDays ?? a.transitDays) - (b.adjustedTransitDays ?? b.transitDays));
return { return {
eco: sorted[0], eco: sorted[0],
@ -281,7 +274,7 @@ export default function SearchResultsPage() {
<div className="bg-white rounded-lg p-4 mb-4"> <div className="bg-white rounded-lg p-4 mb-4">
<div className="text-center mb-3"> <div className="text-center mb-3">
<p className="text-sm text-gray-600 mb-1">{t('totalPrice')}</p> <p className="text-sm text-gray-600 mb-1">{t('totalPrice')}</p>
<p className="text-3xl font-bold text-gray-900">{formatPrice(card.option.priceEUR)}</p> <p className="text-3xl font-bold text-gray-900">{formatPrice(card.option.priceBreakdown.totalPriceForSorting)}</p>
</div> </div>
<div className="border-t border-gray-200 pt-3 space-y-2 text-sm"> <div className="border-t border-gray-200 pt-3 space-y-2 text-sm">
@ -292,7 +285,7 @@ export default function SearchResultsPage() {
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">{t('transit')}</span> <span className="text-gray-600">{t('transit')}</span>
<span className="font-semibold text-gray-900"> <span className="font-semibold text-gray-900">
{t('transitDays', { days: card.option.transitDays })} {t('transitDays', { days: card.option.adjustedTransitDays ?? card.option.transitDays })}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
@ -334,34 +327,32 @@ export default function SearchResultsPage() {
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-3xl font-bold text-blue-600">{formatPrice(result.priceEUR)}</p> <p className="text-3xl font-bold text-blue-600">{formatPrice(result.priceBreakdown.totalPriceForSorting)}</p>
<p className="text-sm text-gray-500">{t('totalPrice')}</p> <p className="text-sm text-gray-500">{t('totalPrice')}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="bg-gray-50 rounded-lg p-3"> <div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.base')}</p> <p className="text-xs text-gray-600 mb-1">Fret ({result.priceBreakdown.freightCurrency})</p>
<p className="font-semibold text-gray-900"> <p className="font-semibold text-gray-900">
{formatPrice(result.priceBreakdown.basePrice)} {formatPrice(result.priceBreakdown.totalFreight)}
</p> </p>
</div> </div>
<div className="bg-gray-50 rounded-lg p-3"> <div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.volume')}</p> <p className="text-xs text-gray-600 mb-1">FOB ({result.priceBreakdown.fobCurrency})</p>
<p className="font-semibold text-gray-900"> <p className="font-semibold text-gray-900">
{formatPrice(result.priceBreakdown.volumeCharge)} {formatPrice(result.priceBreakdown.totalFob)}
</p> </p>
</div> </div>
<div className="bg-gray-50 rounded-lg p-3"> <div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.weight')}</p> <p className="text-xs text-gray-600 mb-1">Routage</p>
<p className="font-semibold text-gray-900"> <p className="font-semibold text-gray-900">{result.routing}</p>
{formatPrice(result.priceBreakdown.weightCharge)}
</p>
</div> </div>
<div className="bg-gray-50 rounded-lg p-3"> <div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.transit')}</p> <p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.transit')}</p>
<p className="font-semibold text-gray-900"> <p className="font-semibold text-gray-900">
{t('transitDays', { days: result.transitDays })} {t('transitDays', { days: result.adjustedTransitDays ?? result.transitDays })}
</p> </p>
</div> </div>
</div> </div>
@ -369,9 +360,14 @@ export default function SearchResultsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-sm text-gray-600"> <div className="flex items-center space-x-4 text-sm text-gray-600">
<span>{t('validUntil', { date: new Date(result.validUntil).toLocaleDateString(dateLocale) })}</span> <span>{t('validUntil', { date: new Date(result.validUntil).toLocaleDateString(dateLocale) })}</span>
{result.hasSurcharges && ( {result.dgSurchargeStatus === 'not_accepted' && (
<span className="text-orange-600 flex items-center"> <span className="text-orange-600 flex items-center">
<AlertTriangle className="h-4 w-4 mr-1" /> {t('surcharges')} <AlertTriangle className="h-4 w-4 mr-1" /> DG non accepté
</span>
)}
{result.dgSurchargeStatus === 'on_request' && (
<span className="text-blue-600 flex items-center">
<AlertTriangle className="h-4 w-4 mr-1" /> DG sur demande
</span> </span>
)} )}
</div> </div>

View File

@ -1,17 +1,17 @@
import { renderHook, act, waitFor } from '@testing-library/react'; import { renderHook, act } from '@testing-library/react';
import { useCsvRateSearch } from '@/hooks/useCsvRateSearch'; import { useCsvRateSearch } from '@/hooks/useCsvRateSearch';
import { searchCsvRates } from '@/lib/api/csv-rates'; import { searchCsvRatesWithOffers } from '@/lib/api/rates';
import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rate-filters'; import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rates';
jest.mock('@/lib/api/csv-rates', () => ({ jest.mock('@/lib/api/rates', () => ({
searchCsvRates: jest.fn(), searchCsvRatesWithOffers: jest.fn(),
})); }));
const mockSearchCsvRates = jest.mocked(searchCsvRates); const mockSearchCsvRatesWithOffers = jest.mocked(searchCsvRatesWithOffers);
const mockRequest: CsvRateSearchRequest = { const mockRequest: CsvRateSearchRequest = {
origin: 'Le Havre', origin: 'FRLEH',
destination: 'Shanghai', destination: 'CNSHA',
volumeCBM: 10, volumeCBM: 10,
weightKG: 5000, weightKG: 5000,
}; };
@ -19,24 +19,58 @@ const mockRequest: CsvRateSearchRequest = {
const mockResponse: CsvRateSearchResponse = { const mockResponse: CsvRateSearchResponse = {
results: [ results: [
{ {
companyName: 'Maersk', companyName: 'SSC Consolidation',
origin: 'Le Havre', companyEmail: 'bookings@ssc.com',
destination: 'Shanghai', originCFS: 'Le Havre',
containerType: '40ft', origin: 'FRLEH',
priceUSD: 2500, portOfLoading: 'LE HAVRE',
priceEUR: 2300, routing: 'Direct',
destinationCFS: 'Shanghai',
destination: 'CNSHA',
destinationCountry: 'China',
containerType: 'LCL',
priceBreakdown: {
freightCharge: 440,
freightCurrency: 'USD',
fobFixed: 173,
fobHandling: 110,
fobDG: 0,
fobCurrency: 'EUR',
fobBreakdown: {
documentation: 55,
isps: 18,
handling: 110,
solas: 15,
customs: 85,
ams_aci: 0,
isf5: 0,
dgAdmin: 0,
},
dgSurchargeAmount: null,
dgSurchargeCurrency: 'EUR',
dgSurchargeStatus: 'computed',
totalFreight: 440,
totalFob: 283,
totalPriceForSorting: 723,
primaryCurrency: 'USD', primaryCurrency: 'USD',
hasSurcharges: false, },
surchargeDetails: null, frequency: 'Weekly',
transitDays: 30, transitDays: 33,
validUntil: '2024-12-31', validUntil: '2026-12-31',
dgAccepted: true,
dgSurchargeStatus: 'computed',
remarks: '',
source: 'CSV', source: 'CSV',
matchScore: 95, matchScore: 110,
serviceLevel: 'STANDARD',
priceMultiplier: 1.0,
originalTransitDays: 33,
adjustedTransitDays: 33,
}, },
], ],
totalResults: 1, totalResults: 1,
searchedFiles: ['maersk-rates.csv'], searchedFiles: ['ssc-consolidation.csv'],
searchedAt: '2024-03-01T10:00:00Z', searchedAt: '2026-05-11T10:00:00Z',
appliedFilters: {}, appliedFilters: {},
}; };
@ -74,8 +108,8 @@ describe('useCsvRateSearch', () => {
describe('search — success path', () => { describe('search — success path', () => {
it('sets loading=true while the request is in flight', async () => { it('sets loading=true while the request is in flight', async () => {
let resolveSearch: (v: CsvRateSearchResponse) => void; let resolveSearch: (v: any) => void;
mockSearchCsvRates.mockReturnValue( mockSearchCsvRatesWithOffers.mockReturnValue(
new Promise(resolve => { new Promise(resolve => {
resolveSearch = resolve; resolveSearch = resolve;
}) })
@ -95,7 +129,7 @@ describe('useCsvRateSearch', () => {
}); });
it('sets data and clears loading after a successful search', async () => { it('sets data and clears loading after a successful search', async () => {
mockSearchCsvRates.mockResolvedValue(mockResponse); mockSearchCsvRatesWithOffers.mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useCsvRateSearch()); const { result } = renderHook(() => useCsvRateSearch());
@ -108,8 +142,8 @@ describe('useCsvRateSearch', () => {
expect(result.current.error).toBeNull(); expect(result.current.error).toBeNull();
}); });
it('calls searchCsvRates with the given request', async () => { it('calls searchCsvRatesWithOffers with the given request', async () => {
mockSearchCsvRates.mockResolvedValue(mockResponse); mockSearchCsvRatesWithOffers.mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useCsvRateSearch()); const { result } = renderHook(() => useCsvRateSearch());
@ -117,22 +151,20 @@ describe('useCsvRateSearch', () => {
await result.current.search(mockRequest); await result.current.search(mockRequest);
}); });
expect(mockSearchCsvRates).toHaveBeenCalledWith(mockRequest); expect(mockSearchCsvRatesWithOffers).toHaveBeenCalledWith(mockRequest);
}); });
it('clears a previous error when a new search starts', async () => { it('clears a previous error when a new search starts', async () => {
mockSearchCsvRates.mockRejectedValueOnce(new Error('first error')); mockSearchCsvRatesWithOffers.mockRejectedValueOnce(new Error('first error'));
mockSearchCsvRates.mockResolvedValueOnce(mockResponse); mockSearchCsvRatesWithOffers.mockResolvedValueOnce(mockResponse as any);
const { result } = renderHook(() => useCsvRateSearch()); const { result } = renderHook(() => useCsvRateSearch());
// First search fails
await act(async () => { await act(async () => {
await result.current.search(mockRequest); await result.current.search(mockRequest);
}); });
expect(result.current.error).toBe('first error'); expect(result.current.error).toBe('first error');
// Second search succeeds — error must be cleared
await act(async () => { await act(async () => {
await result.current.search(mockRequest); await result.current.search(mockRequest);
}); });
@ -142,7 +174,7 @@ describe('useCsvRateSearch', () => {
describe('search — error path', () => { describe('search — error path', () => {
it('sets error and clears data when the API throws', async () => { it('sets error and clears data when the API throws', async () => {
mockSearchCsvRates.mockRejectedValue(new Error('Network error')); mockSearchCsvRatesWithOffers.mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useCsvRateSearch()); const { result } = renderHook(() => useCsvRateSearch());
@ -156,7 +188,7 @@ describe('useCsvRateSearch', () => {
}); });
it('uses a default error message when the error has no message', async () => { it('uses a default error message when the error has no message', async () => {
mockSearchCsvRates.mockRejectedValue({}); mockSearchCsvRatesWithOffers.mockRejectedValue({});
const { result } = renderHook(() => useCsvRateSearch()); const { result } = renderHook(() => useCsvRateSearch());
@ -164,13 +196,13 @@ describe('useCsvRateSearch', () => {
await result.current.search(mockRequest); await result.current.search(mockRequest);
}); });
expect(result.current.error).toBe('Failed to search rates'); expect(result.current.error).toBe('Erreur lors de la recherche de tarifs');
}); });
}); });
describe('reset', () => { describe('reset', () => {
it('clears data, error, and loading', async () => { it('clears data, error, and loading', async () => {
mockSearchCsvRates.mockResolvedValue(mockResponse); mockSearchCsvRatesWithOffers.mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useCsvRateSearch()); const { result } = renderHook(() => useCsvRateSearch());

View File

@ -1,12 +1,3 @@
/**
* CSV Rate Search Page
*
* Complete rate search page with:
* - Volume/Weight/Pallet input
* - Advanced filters panel
* - Results table with CSV/API source badges
*/
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
@ -15,22 +6,21 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Switch } from '@/components/ui/switch';
import { Loader2, Search } from 'lucide-react'; import { Loader2, Search } from 'lucide-react';
import { VolumeWeightInput } from '@/components/rate-search/VolumeWeightInput';
import { RateFiltersPanel } from '@/components/rate-search/RateFiltersPanel'; import { RateFiltersPanel } from '@/components/rate-search/RateFiltersPanel';
import { RateResultsTable } from '@/components/rate-search/RateResultsTable'; import { RateResultsTable } from '@/components/rate-search/RateResultsTable';
import { useCsvRateSearch } from '@/hooks/useCsvRateSearch'; import { useCsvRateSearch } from '@/hooks/useCsvRateSearch';
import type { RateSearchFilters } from '@/types/rate-filters'; import type { RateSearchFilters } from '@/types/rate-filters';
import type { CsvRateSearchResult } from '@/types/rates';
export default function CsvRateSearchPage() { export default function CsvRateSearchPage() {
// Search parameters const [origin, setOrigin] = useState('FRFOS');
const [origin, setOrigin] = useState('NLRTM'); const [destination, setDestination] = useState('CNSHA');
const [destination, setDestination] = useState('USNYC'); const [volumeCBM, setVolumeCBM] = useState(10);
const [volumeCBM, setVolumeCBM] = useState(25.5); const [weightKG, setWeightKG] = useState(2500);
const [weightKG, setWeightKG] = useState(3500); const [hasDangerousGoods, setHasDangerousGoods] = useState(false);
const [palletCount, setPalletCount] = useState(10);
const [filters, setFilters] = useState<RateSearchFilters>({}); const [filters, setFilters] = useState<RateSearchFilters>({});
const [currency, setCurrency] = useState<'USD' | 'EUR'>('USD');
const { data, loading, error, search } = useCsvRateSearch(); const { data, loading, error, search } = useCsvRateSearch();
@ -40,54 +30,49 @@ export default function CsvRateSearchPage() {
destination, destination,
volumeCBM, volumeCBM,
weightKG, weightKG,
palletCount,
containerType: 'LCL', containerType: 'LCL',
hasDangerousGoods,
filters, filters,
}); });
}; };
const handleResetFilters = () => { const handleBooking = (result: CsvRateSearchResult) => {
setFilters({}); alert(
}; `Réservation pour ${result.companyName}\nContact : ${result.companyEmail}\nRoute : ${result.originCFS}${result.destinationCFS}`
);
const handleBooking = (result: any) => {
alert(`Booking pour ${result.companyName}: ${result.origin}${result.destination}`);
// TODO: Implement actual booking flow
}; };
return ( return (
<div className="container mx-auto py-8 space-y-6"> <div className="container mx-auto py-8 space-y-6">
{/* Page Header */}
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Recherche de tarifs CSV</h1> <h1 className="text-3xl font-bold tracking-tight">Comparateur de tarifs LCL</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Recherchez des tarifs de transport maritime avec filtres avancés Comparez les tarifs de fret maritime LCL multi-fournisseurs avec détail FOB et fret
</p> </p>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Left Column: Filters */} {/* Filtres */}
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<RateFiltersPanel <RateFiltersPanel
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={setFilters}
resultsCount={data?.totalResults || 0} resultsCount={data?.totalResults || 0}
onReset={handleResetFilters} onReset={() => setFilters({})}
/> />
</div> </div>
{/* Right Column: Search Form + Results */} {/* Formulaire + résultats */}
<div className="lg:col-span-3 space-y-6"> <div className="lg:col-span-3 space-y-6">
{/* Search Form */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Paramètres de recherche</CardTitle> <CardTitle>Paramètres de recherche</CardTitle>
<CardDescription> <CardDescription>
Indiquez votre trajet et les dimensions de votre envoi Indiquez votre trajet, volume et poids pour obtenir des tarifs comparés
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Origin and Destination */} {/* Origine / Destination */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="origin"> <Label htmlFor="origin">
@ -97,13 +82,11 @@ export default function CsvRateSearchPage() {
id="origin" id="origin"
value={origin} value={origin}
onChange={e => setOrigin(e.target.value.toUpperCase())} onChange={e => setOrigin(e.target.value.toUpperCase())}
placeholder="NLRTM" placeholder="FRFOS"
maxLength={5} maxLength={5}
required
/> />
<p className="text-xs text-muted-foreground">Code UN/LOCODE (5 caractères)</p> <p className="text-xs text-muted-foreground">Code UN/LOCODE (ex: FRFOS, FRLEH)</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="destination"> <Label htmlFor="destination">
Port de destination <span className="text-red-500">*</span> Port de destination <span className="text-red-500">*</span>
@ -112,49 +95,56 @@ export default function CsvRateSearchPage() {
id="destination" id="destination"
value={destination} value={destination}
onChange={e => setDestination(e.target.value.toUpperCase())} onChange={e => setDestination(e.target.value.toUpperCase())}
placeholder="USNYC" placeholder="CNSHA"
maxLength={5} maxLength={5}
required
/> />
<p className="text-xs text-muted-foreground">Code UN/LOCODE (5 caractères)</p> <p className="text-xs text-muted-foreground">Code UN/LOCODE (ex: CNSHA, USNYC)</p>
</div> </div>
</div> </div>
{/* Volume, Weight, Pallets */} {/* Volume / Poids */}
<VolumeWeightInput <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
volumeCBM={volumeCBM}
weightKG={weightKG}
palletCount={palletCount}
onVolumeChange={setVolumeCBM}
onWeightChange={setWeightKG}
onPalletChange={setPalletCount}
disabled={loading}
/>
{/* Currency Selection */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Devise d'affichage</Label> <Label htmlFor="volume">
<div className="flex gap-2"> Volume (CBM) <span className="text-red-500">*</span>
<Button </Label>
type="button" <Input
variant={currency === 'USD' ? 'default' : 'outline'} id="volume"
onClick={() => setCurrency('USD')} type="number"
disabled={loading} min={0.1}
> step={0.1}
USD ($) value={volumeCBM}
</Button> onChange={e => setVolumeCBM(parseFloat(e.target.value) || 0)}
<Button />
type="button" </div>
variant={currency === 'EUR' ? 'default' : 'outline'} <div className="space-y-2">
onClick={() => setCurrency('EUR')} <Label htmlFor="weight">
disabled={loading} Poids (kg) <span className="text-red-500">*</span>
> </Label>
EUR () <Input
</Button> id="weight"
type="number"
min={1}
step={100}
value={weightKG}
onChange={e => setWeightKG(parseInt(e.target.value, 10) || 0)}
/>
</div> </div>
</div> </div>
{/* Search Button */} {/* Marchandises dangereuses */}
<div className="flex items-center space-x-2">
<Switch
id="dg"
checked={hasDangerousGoods}
onCheckedChange={setHasDangerousGoods}
/>
<Label htmlFor="dg" className="cursor-pointer">
Marchandises dangereuses (DG)
</Label>
</div>
{/* Recherche */}
<Button <Button
onClick={handleSearch} onClick={handleSearch}
disabled={loading || !origin || !destination || volumeCBM <= 0 || weightKG <= 0} disabled={loading || !origin || !destination || volumeCBM <= 0 || weightKG <= 0}
@ -169,58 +159,49 @@ export default function CsvRateSearchPage() {
) : ( ) : (
<> <>
<Search className="mr-2 h-4 w-4" /> <Search className="mr-2 h-4 w-4" />
Rechercher des tarifs Comparer les tarifs
</> </>
)} )}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
{/* Error Alert */}
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>
)} )}
{/* Search Info */}
{data && ( {data && (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
Recherche effectuée le {new Date(data.searchedAt).toLocaleString('fr-FR')} {' '} {new Date(data.searchedAt).toLocaleString('fr-FR')} {data.searchedFiles.length}{' '}
{data.searchedFiles.length} fichier(s) CSV analysé(s) {data.totalResults} tarif(s) fournisseur(s) analysé(s) {data.totalResults} tarif(s) trouvé(s)
trouvé(s)
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{/* Results Table */}
{data && data.results.length > 0 && ( {data && data.results.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Résultats de recherche</CardTitle> <CardTitle>Résultats de comparaison</CardTitle>
<CardDescription> <CardDescription>
{data.totalResults} tarif{data.totalResults > 1 ? 's' : ''} correspondant à vos {data.totalResults} tarif{data.totalResults > 1 ? 's' : ''} prix = Fret +
critères Frais FOB (deux devises possibles)
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<RateResultsTable <RateResultsTable results={data.results} onBooking={handleBooking} />
results={data.results}
currency={currency}
onBooking={handleBooking}
/>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* No Results */}
{data && data.results.length === 0 && ( {data && data.results.length === 0 && (
<Card> <Card>
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
<p className="text-muted-foreground">Aucun tarif trouvé pour cette recherche.</p> <p className="text-muted-foreground">Aucun tarif trouvé pour cette route.</p>
<p className="text-sm text-muted-foreground mt-2"> <p className="text-sm text-muted-foreground mt-2">
Essayez d'ajuster vos critères de recherche ou vos filtres. Vérifiez les codes UN/LOCODE saisis (ex: FRFOS, CNSHA).
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,13 +1,5 @@
/**
* Rate Filters Panel Component
*
* Advanced filters panel for rate search
* Includes all filter options: companies, volume, weight, price, transit, etc.
*/
'use client'; 'use client';
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -39,188 +31,98 @@ export function RateFiltersPanel({
}: RateFiltersPanelProps) { }: RateFiltersPanelProps) {
const { companies, containerTypes, loading } = useFilterOptions(); const { companies, containerTypes, loading } = useFilterOptions();
const updateFilter = <K extends keyof RateSearchFilters>(key: K, value: RateSearchFilters[K]) => { const update = <K extends keyof RateSearchFilters>(key: K, value: RateSearchFilters[K]) => {
onFiltersChange({ onFiltersChange({ ...filters, [key]: value });
...filters,
[key]: value,
});
};
const handleReset = () => {
onReset();
}; };
return ( return (
<Card className="h-full"> <Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-lg font-semibold">Filtres</CardTitle> <CardTitle className="text-lg font-semibold">Filtres</CardTitle>
<Button variant="ghost" size="sm" onClick={handleReset}> <Button variant="ghost" size="sm" onClick={onReset}>
Réinitialiser Réinitialiser
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Compagnies */} {/* Fournisseurs */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Compagnies maritimes</Label> <Label>Fournisseurs</Label>
<CompanyMultiSelect <CompanyMultiSelect
companies={companies} companies={companies}
selected={filters.companies || []} selected={filters.companies || []}
onChange={selected => updateFilter('companies', selected)} onChange={selected => update('companies', selected)}
disabled={loading} disabled={loading}
/> />
</div> </div>
{/* Volume CBM */} {/* Routing direct uniquement */}
<div className="space-y-2"> <div className="flex items-center space-x-2">
<Label>Volume (CBM)</Label> <Switch
<div className="grid grid-cols-2 gap-2"> id="only-direct"
<div> checked={filters.onlyDirect || false}
<Input onCheckedChange={checked => update('onlyDirect', checked)}
type="number"
min={0}
step={0.1}
placeholder="Min"
value={filters.minVolumeCBM || ''}
onChange={e =>
updateFilter('minVolumeCBM', parseFloat(e.target.value) || undefined)
}
/> />
</div> <Label htmlFor="only-direct" className="cursor-pointer">
<div> Routing direct uniquement
<Input </Label>
type="number"
min={0}
step={0.1}
placeholder="Max"
value={filters.maxVolumeCBM || ''}
onChange={e =>
updateFilter('maxVolumeCBM', parseFloat(e.target.value) || undefined)
}
/>
</div>
</div>
</div> </div>
{/* Poids (kg) */} {/* Exclure les routes sans DG */}
<div className="space-y-2"> <div className="flex items-center space-x-2">
<Label>Poids (kg)</Label> <Switch
<div className="grid grid-cols-2 gap-2"> id="dg-routes"
<div> checked={filters.excludeNonDgRoutes || false}
<Input onCheckedChange={checked => update('excludeNonDgRoutes', checked)}
type="number"
min={0}
step={100}
placeholder="Min"
value={filters.minWeightKG || ''}
onChange={e =>
updateFilter('minWeightKG', parseInt(e.target.value, 10) || undefined)
}
/> />
</div> <Label htmlFor="dg-routes" className="cursor-pointer">
<div> Acceptation DG requise
<Input </Label>
type="number"
min={0}
step={100}
placeholder="Max"
value={filters.maxWeightKG || ''}
onChange={e =>
updateFilter('maxWeightKG', parseInt(e.target.value, 10) || undefined)
}
/>
</div>
</div>
</div> </div>
{/* Palettes */} {/* Prix (total estimé) */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Nombre de palettes</Label> <Label>Prix estimé total</Label>
<Input
type="number"
min={0}
placeholder="Ex: 10"
value={filters.palletCount || ''}
onChange={e => updateFilter('palletCount', parseInt(e.target.value, 10) || undefined)}
/>
<p className="text-xs text-muted-foreground">Laisser vide pour ignorer</p>
</div>
{/* Prix */}
<div className="space-y-2">
<Label>Prix (en devise sélectionnée)</Label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div>
<Input <Input
type="number" type="number"
min={0} min={0}
step={100} step={100}
placeholder="Min" placeholder="Min"
value={filters.minPrice || ''} value={filters.minPrice || ''}
onChange={e => updateFilter('minPrice', parseFloat(e.target.value) || undefined)} onChange={e => update('minPrice', parseFloat(e.target.value) || undefined)}
/> />
</div>
<div>
<Input <Input
type="number" type="number"
min={0} min={0}
step={100} step={100}
placeholder="Max" placeholder="Max"
value={filters.maxPrice || ''} value={filters.maxPrice || ''}
onChange={e => updateFilter('maxPrice', parseFloat(e.target.value) || undefined)} onChange={e => update('maxPrice', parseFloat(e.target.value) || undefined)}
/> />
</div> </div>
</div> </div>
</div>
{/* Devise */}
<div className="space-y-2">
<Label>Devise</Label>
<Select
value={filters.currency || 'all'}
onValueChange={value =>
updateFilter('currency', value === 'all' ? undefined : (value as 'USD' | 'EUR'))
}
>
<SelectTrigger>
<SelectValue placeholder="Toutes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes</SelectItem>
<SelectItem value="USD">USD ($)</SelectItem>
<SelectItem value="EUR">EUR ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* Durée de transit */} {/* Durée de transit */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Durée de transit (jours)</Label> <Label>Transit (jours)</Label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div>
<Input <Input
type="number" type="number"
min={0} min={0}
placeholder="Min" placeholder="Min"
value={filters.minTransitDays || ''} value={filters.minTransitDays || ''}
onChange={e => onChange={e => update('minTransitDays', parseInt(e.target.value, 10) || undefined)}
updateFilter('minTransitDays', parseInt(e.target.value, 10) || undefined)
}
/> />
</div>
<div>
<Input <Input
type="number" type="number"
min={0} min={0}
placeholder="Max" placeholder="Max"
value={filters.maxTransitDays || ''} value={filters.maxTransitDays || ''}
onChange={e => onChange={e => update('maxTransitDays', parseInt(e.target.value, 10) || undefined)}
updateFilter('maxTransitDays', parseInt(e.target.value, 10) || undefined)
}
/> />
</div> </div>
</div> </div>
</div>
{/* Type de conteneur */} {/* Type de conteneur */}
<div className="space-y-2"> <div className="space-y-2">
@ -228,7 +130,7 @@ export function RateFiltersPanel({
<Select <Select
value={filters.containerTypes?.[0] || 'all'} value={filters.containerTypes?.[0] || 'all'}
onValueChange={value => onValueChange={value =>
updateFilter('containerTypes', value === 'all' ? undefined : [value]) update('containerTypes', value === 'all' ? undefined : [value])
} }
> >
<SelectTrigger> <SelectTrigger>
@ -245,35 +147,23 @@ export function RateFiltersPanel({
</Select> </Select>
</div> </div>
{/* Prix all-in uniquement */} {/* Date de validité */}
<div className="flex items-center space-x-2">
<Switch
id="only-all-in"
checked={filters.onlyAllInPrices || false}
onCheckedChange={checked => updateFilter('onlyAllInPrices', checked)}
/>
<Label htmlFor="only-all-in" className="cursor-pointer">
Uniquement prix tout compris (sans surcharges séparées)
</Label>
</div>
{/* Date de départ */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Date de départ</Label> <Label>Date de départ</Label>
<Input <Input
type="date" type="date"
value={filters.departureDate || ''} value={filters.departureDate || ''}
onChange={e => updateFilter('departureDate', e.target.value || undefined)} onChange={e => update('departureDate', e.target.value || undefined)}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Filtrer par validité des tarifs à cette date Filtre les tarifs valides à cette date
</p> </p>
</div> </div>
{/* Résultats */} {/* Résultats */}
<div className="pt-4 border-t"> <div className="pt-4 border-t">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">Résultats trouvés</span> <span className="text-sm font-medium">Résultats</span>
<span className="text-2xl font-bold text-primary">{resultsCount}</span> <span className="text-2xl font-bold text-primary">{resultsCount}</span>
</div> </div>
</div> </div>

View File

@ -1,10 +1,3 @@
/**
* Rate Results Table Component
*
* Displays search results in a table format
* Shows CSV/API source, prices, transit time, and surcharge details
*/
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
@ -26,19 +19,31 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { ArrowUpDown, Info } from 'lucide-react'; import { ArrowUpDown, Info, Mail, AlertTriangle } from 'lucide-react';
import type { CsvRateResult } from '@/types/rate-filters'; import type { CsvRateSearchResult, ServiceLevel } from '@/types/rates';
interface RateResultsTableProps { interface RateResultsTableProps {
results: CsvRateResult[]; results: CsvRateSearchResult[];
currency?: 'USD' | 'EUR'; onBooking?: (result: CsvRateSearchResult) => void;
onBooking?: (result: CsvRateResult) => void;
} }
type SortField = 'price' | 'transit' | 'company' | 'matchScore'; type SortField = 'price' | 'transit' | 'company' | 'matchScore';
type SortOrder = 'asc' | 'desc'; type SortOrder = 'asc' | 'desc';
export function RateResultsTable({ results, currency = 'USD', onBooking }: RateResultsTableProps) { const SERVICE_LEVEL_LABELS: Record<ServiceLevel, { label: string; color: string }> = {
ECONOMIC: { label: 'Éco', color: 'bg-green-100 text-green-800 border-green-200' },
STANDARD: { label: 'Standard', color: 'bg-blue-100 text-blue-800 border-blue-200' },
RAPID: { label: 'Rapide', color: 'bg-orange-100 text-orange-800 border-orange-200' },
};
const FREQUENCY_BADGE: Record<string, string> = {
Weekly: 'bg-emerald-100 text-emerald-700',
'Bi-Weekly': 'bg-sky-100 text-sky-700',
'Bi-Monthly': 'bg-amber-100 text-amber-700',
Monthly: 'bg-rose-100 text-rose-700',
};
export function RateResultsTable({ results, onBooking }: RateResultsTableProps) {
const [sortField, setSortField] = useState<SortField>('price'); const [sortField, setSortField] = useState<SortField>('price');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc'); const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
@ -52,45 +57,33 @@ export function RateResultsTable({ results, currency = 'USD', onBooking }: RateR
}; };
const sortedResults = [...results].sort((a, b) => { const sortedResults = [...results].sort((a, b) => {
let aValue: number | string; let aVal: number | string;
let bValue: number | string; let bVal: number | string;
switch (sortField) { switch (sortField) {
case 'price': case 'price':
aValue = currency === 'USD' ? a.priceUSD : a.priceEUR; aVal = a.priceBreakdown.totalPriceForSorting;
bValue = currency === 'USD' ? b.priceUSD : b.priceEUR; bVal = b.priceBreakdown.totalPriceForSorting;
break; break;
case 'transit': case 'transit':
aValue = a.transitDays; aVal = a.transitDays;
bValue = b.transitDays; bVal = b.transitDays;
break; break;
case 'company': case 'company':
aValue = a.companyName; aVal = a.companyName;
bValue = b.companyName; bVal = b.companyName;
break; break;
case 'matchScore': case 'matchScore':
aValue = a.matchScore; aVal = a.matchScore;
bValue = b.matchScore; bVal = b.matchScore;
break; break;
default: default:
return 0; return 0;
} }
if (sortOrder === 'asc') { return sortOrder === 'asc' ? (aVal > bVal ? 1 : -1) : aVal < bVal ? 1 : -1;
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
}); });
const formatPrice = (priceUSD: number, priceEUR: number) => {
if (currency === 'USD') {
return `$${priceUSD.toFixed(2)}`;
} else {
return `${priceEUR.toFixed(2)}`;
}
};
const SortButton = ({ field, label }: { field: SortField; label: string }) => ( const SortButton = ({ field, label }: { field: SortField; label: string }) => (
<button <button
onClick={() => handleSort(field)} onClick={() => handleSort(field)}
@ -113,23 +106,25 @@ export function RateResultsTable({ results, currency = 'USD', onBooking }: RateR
} }
return ( return (
<div className="rounded-md border"> <div className="rounded-md border overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead> <TableHead>
<SortButton field="company" label="Compagnie" /> <SortButton field="company" label="Fournisseur" />
</TableHead> </TableHead>
<TableHead>Source</TableHead> <TableHead>Niveau</TableHead>
<TableHead>Trajet</TableHead> <TableHead>Route</TableHead>
<TableHead>Routing</TableHead>
<TableHead>Fréquence</TableHead>
<TableHead> <TableHead>
<SortButton field="price" label="Prix" /> <SortButton field="price" label="Prix estimé" />
</TableHead> </TableHead>
<TableHead>Surcharges</TableHead>
<TableHead> <TableHead>
<SortButton field="transit" label="Transit" /> <SortButton field="transit" label="Transit" />
</TableHead> </TableHead>
<TableHead>Validité</TableHead> <TableHead>Validité</TableHead>
<TableHead>DG</TableHead>
<TableHead> <TableHead>
<SortButton field="matchScore" label="Score" /> <SortButton field="matchScore" label="Score" />
</TableHead> </TableHead>
@ -137,92 +132,132 @@ export function RateResultsTable({ results, currency = 'USD', onBooking }: RateR
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{sortedResults.map((result, index) => ( {sortedResults.map((result, index) => {
<TableRow key={index}> const bd = result.priceBreakdown;
{/* Compagnie */} const sl = result.serviceLevel as ServiceLevel | undefined;
<TableCell className="font-medium">{result.companyName}</TableCell> const slInfo = sl ? SERVICE_LEVEL_LABELS[sl] : null;
{/* Source (CSV/API) */} return (
<TableRow key={index}>
{/* Fournisseur */}
<TableCell> <TableCell>
<Badge variant={result.source === 'CSV' ? 'secondary' : 'default'}> <div className="font-medium text-sm">{result.companyName}</div>
{result.source} <div className="text-xs text-muted-foreground flex items-center gap-1">
</Badge> <Mail className="h-3 w-3" />
{result.companyEmail}
</div>
</TableCell> </TableCell>
{/* Trajet */} {/* Niveau de service */}
<TableCell> <TableCell>
<div className="text-sm"> {slInfo ? (
<div> <span className={`text-xs font-medium px-2 py-1 rounded border ${slInfo.color}`}>
{result.origin} {result.destination} {slInfo.label}
{result.priceMultiplier && result.priceMultiplier !== 1 && (
<span className="ml-1 opacity-70">
{result.priceMultiplier > 1 ? '+' : ''}
{Math.round((result.priceMultiplier - 1) * 100)}%
</span>
)}
</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
{/* Route */}
<TableCell>
<div className="text-sm font-medium">
{result.originCFS || result.origin}
</div> </div>
<div className="text-muted-foreground">{result.containerType}</div> <div className="text-xs text-muted-foreground"> {result.destinationCFS || result.destination}</div>
<div className="text-xs text-muted-foreground">
({result.origin} / {result.destination})
</div> </div>
</TableCell> </TableCell>
{/* Routing */}
<TableCell>
<Badge
variant="outline"
className={
result.routing.toLowerCase() === 'direct'
? 'border-green-500 text-green-700'
: 'border-amber-500 text-amber-700'
}
>
{result.routing}
</Badge>
{result.portOfLoading && result.portOfLoading !== result.origin && (
<div className="text-xs text-muted-foreground mt-1">
via {result.portOfLoading}
</div>
)}
</TableCell>
{/* Fréquence */}
<TableCell>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
FREQUENCY_BADGE[result.frequency] || 'bg-gray-100 text-gray-700'
}`}
>
{result.frequency}
</span>
</TableCell>
{/* Prix */} {/* Prix */}
<TableCell> <TableCell>
<div className="font-semibold">{formatPrice(result.priceUSD, result.priceEUR)}</div> <PriceCell result={result} />
{result.hasSurcharges && (
<div className="text-xs text-orange-600">+ surcharges</div>
)}
</TableCell>
{/* Surcharges */}
<TableCell>
{result.hasSurcharges ? (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="gap-1">
<Info className="h-3 w-3" />
Détails
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Détails des surcharges</DialogTitle>
<DialogDescription>
{result.companyName} - {result.origin} {result.destination}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm">{result.surchargeDetails}</p>
</div>
</DialogContent>
</Dialog>
) : (
<Badge variant="outline" className="text-green-600 border-green-600">
All-in
</Badge>
)}
</TableCell> </TableCell>
{/* Transit */} {/* Transit */}
<TableCell> <TableCell>
<div className="text-sm">{result.transitDays} jours</div> <div className="text-sm font-medium">{result.transitDays} j</div>
{result.originalTransitDays && result.originalTransitDays !== result.transitDays && (
<div className="text-xs text-muted-foreground line-through">
{result.originalTransitDays} j
</div>
)}
</TableCell> </TableCell>
{/* Validité */} {/* Validité */}
<TableCell> <TableCell>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Jusqu'au {new Date(result.validUntil).toLocaleDateString('fr-FR')} {new Date(result.validUntil).toLocaleDateString('fr-FR')}
</div> </div>
</TableCell> </TableCell>
{/* DG */}
<TableCell>
{!result.dgAccepted ? (
<span title="DG non accepté" className="text-red-500">
<AlertTriangle className="h-4 w-4" />
</span>
) : result.dgSurchargeStatus === 'on_request' ? (
<Badge variant="outline" className="text-xs border-amber-400 text-amber-700">
Sur devis
</Badge>
) : (
<Badge variant="outline" className="text-xs border-green-400 text-green-700">
Accepté
</Badge>
)}
</TableCell>
{/* Score */} {/* Score */}
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <span
<div
className={`text-sm font-medium ${ className={`text-sm font-medium ${
result.matchScore >= 90 result.matchScore >= 105
? 'text-green-600' ? 'text-green-600'
: result.matchScore >= 75 : result.matchScore >= 95
? 'text-yellow-600' ? 'text-blue-600'
: 'text-gray-600' : 'text-gray-500'
}`} }`}
> >
{result.matchScore}% {result.matchScore}
</div> </span>
</div>
</TableCell> </TableCell>
{/* Actions */} {/* Actions */}
@ -232,24 +267,177 @@ export function RateResultsTable({ results, currency = 'USD', onBooking }: RateR
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} );
})}
</TableBody> </TableBody>
</Table> </Table>
{/* Summary footer */} <div className="p-4 border-t bg-muted/50 text-sm text-muted-foreground">
<div className="p-4 border-t bg-muted/50"> {results.length} tarif{results.length > 1 ? 's' : ''} trouvé{results.length > 1 ? 's' : ''}
<div className="flex items-center justify-between text-sm"> {' '} Prix estimés : Fret (USD/EUR) + FOB (EUR). Les devises peuvent différer selon le fournisseur.
<span className="text-muted-foreground">
{results.length} tarif{results.length > 1 ? 's' : ''} trouvé
{results.length > 1 ? 's' : ''}
</span>
<div className="flex items-center gap-4">
<span className="text-muted-foreground">
Prix affichés en <strong>{currency}</strong>
</span>
</div>
</div>
</div> </div>
</div> </div>
); );
} }
function PriceCell({ result }: { result: CsvRateSearchResult }) {
const bd = result.priceBreakdown;
return (
<Dialog>
<DialogTrigger asChild>
<button className="text-left hover:underline group">
<div className="font-semibold text-sm">
{bd.freightCurrency} {bd.totalFreight.toFixed(0)}
<span className="text-muted-foreground font-normal text-xs ml-1">fret</span>
</div>
<div className="text-xs text-muted-foreground">
+ {bd.fobCurrency} {bd.totalFob.toFixed(0)} FOB
</div>
<div className="flex items-center gap-1 text-xs text-blue-600 group-hover:underline">
<Info className="h-3 w-3" />
Détail
</div>
</button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Détail du prix estimé</DialogTitle>
<DialogDescription>
{result.companyName} {result.originCFS || result.origin} {' '}
{result.destinationCFS || result.destination}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Fret */}
<div>
<h4 className="font-medium text-sm mb-2">
Fret ({bd.freightCurrency})
</h4>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Taux de fret</span>
<span>
{bd.freightCurrency} {bd.freightCharge.toFixed(2)}
</span>
</div>
</div>
{/* FOB */}
<div>
<h4 className="font-medium text-sm mb-2">
Frais FOB ({bd.fobCurrency})
</h4>
<div className="space-y-1 text-sm">
{bd.fobBreakdown.documentation > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Documentation</span>
<span>{bd.fobBreakdown.documentation}</span>
</div>
)}
{bd.fobBreakdown.isps > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">ISPS</span>
<span>{bd.fobBreakdown.isps}</span>
</div>
)}
{bd.fobBreakdown.handling > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Manutention</span>
<span>{bd.fobBreakdown.handling.toFixed(2)}</span>
</div>
)}
{bd.fobBreakdown.solas > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">SOLAS</span>
<span>{bd.fobBreakdown.solas}</span>
</div>
)}
{bd.fobBreakdown.customs > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Douane export</span>
<span>{bd.fobBreakdown.customs}</span>
</div>
)}
{bd.fobBreakdown.ams_aci > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">AMS/ACI</span>
<span>{bd.fobBreakdown.ams_aci}</span>
</div>
)}
{bd.fobBreakdown.isf5 > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">ISF5</span>
<span>{bd.fobBreakdown.isf5}</span>
</div>
)}
{bd.fobBreakdown.dgAdmin > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Admin DG</span>
<span>{bd.fobBreakdown.dgAdmin}</span>
</div>
)}
</div>
</div>
{/* DG surcharge if applicable */}
{bd.dgSurchargeStatus === 'on_request' && (
<div className="rounded bg-amber-50 border border-amber-200 px-3 py-2 text-sm text-amber-800">
Surcharge DG : sur demande contactez le fournisseur
</div>
)}
{bd.dgSurchargeStatus === 'not_accepted' && (
<div className="rounded bg-red-50 border border-red-200 px-3 py-2 text-sm text-red-800">
DG non accepté sur cette route
</div>
)}
{bd.dgSurchargeAmount !== null && bd.dgSurchargeAmount > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Surcharge DG ({bd.dgSurchargeCurrency})
</span>
<span>{bd.dgSurchargeAmount.toFixed(2)}</span>
</div>
)}
{/* Totals */}
<div className="border-t pt-3 space-y-1">
<div className="flex justify-between text-sm font-medium">
<span>Total Fret</span>
<span>
{bd.freightCurrency} {bd.totalFreight.toFixed(2)}
</span>
</div>
<div className="flex justify-between text-sm font-medium">
<span>Total FOB</span>
<span>
{bd.fobCurrency} {bd.totalFob.toFixed(2)}
</span>
</div>
{bd.freightCurrency !== bd.fobCurrency && (
<p className="text-xs text-muted-foreground mt-2">
Le fret est en {bd.freightCurrency} et le FOB en {bd.fobCurrency}. Un taux de
change est nécessaire pour le total exact.
</p>
)}
</div>
{/* Contact */}
<div className="border-t pt-3">
<p className="text-xs text-muted-foreground">
Contact booking :{' '}
<a
href={`mailto:${result.companyEmail}`}
className="text-primary hover:underline"
>
{result.companyEmail}
</a>
</p>
{result.remarks && (
<p className="text-xs text-muted-foreground mt-1">Ref: {result.remarks}</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,12 +1,13 @@
/** /**
* CSV Rate Search Hook * CSV Rate Search Hook
* *
* React hook for searching CSV-based rates with filters * React hook for searching CSV-based rates with service level offers
* (ECONOMIC / STANDARD / RAPID).
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { searchCsvRates } from '@/lib/api/csv-rates'; import { searchCsvRatesWithOffers } from '@/lib/api/rates';
import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rate-filters'; import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rates';
interface UseCsvRateSearchResult { interface UseCsvRateSearchResult {
data: CsvRateSearchResponse | null; data: CsvRateSearchResponse | null;
@ -26,10 +27,10 @@ export function useCsvRateSearch(): UseCsvRateSearchResult {
setError(null); setError(null);
try { try {
const response = await searchCsvRates(request); const response = await searchCsvRatesWithOffers(request);
setData(response); setData(response as unknown as CsvRateSearchResponse);
} catch (err: any) { } catch (err: any) {
setError(err?.message || 'Failed to search rates'); setError(err?.message || 'Erreur lors de la recherche de tarifs');
setData(null); setData(null);
} finally { } finally {
setLoading(false); setLoading(false);
@ -42,11 +43,5 @@ export function useCsvRateSearch(): UseCsvRateSearchResult {
setLoading(false); setLoading(false);
}; };
return { return { data, loading, error, search, reset };
data,
loading,
error,
search,
reset,
};
} }

View File

@ -28,28 +28,28 @@ export interface CreateApiKeyRequest {
/** /**
* List all API keys for the current organization * List all API keys for the current organization
* GET /api-keys * GET /api/v1/api-keys
* Requires: Gold or Platinum plan * Requires: Gold or Platinum plan
*/ */
export async function listApiKeys(): Promise<ApiKeyDto[]> { export async function listApiKeys(): Promise<ApiKeyDto[]> {
return get<ApiKeyDto[]>('/api-keys'); return get<ApiKeyDto[]>('/api/v1/api-keys');
} }
/** /**
* Create a new API key * Create a new API key
* POST /api-keys * POST /api/v1/api-keys
* Requires: Gold or Platinum plan * Requires: Gold or Platinum plan
* Returns the full key shown only once * Returns the full key shown only once
*/ */
export async function createApiKey(data: CreateApiKeyRequest): Promise<CreateApiKeyResultDto> { export async function createApiKey(data: CreateApiKeyRequest): Promise<CreateApiKeyResultDto> {
return post<CreateApiKeyResultDto>('/api-keys', data); return post<CreateApiKeyResultDto>('/api/v1/api-keys', data);
} }
/** /**
* Revoke an API key (immediate and irreversible) * Revoke an API key (immediate and irreversible)
* DELETE /api-keys/:id * DELETE /api/v1/api-keys/:id
* Requires: Gold or Platinum plan * Requires: Gold or Platinum plan
*/ */
export async function revokeApiKey(id: string): Promise<void> { export async function revokeApiKey(id: string): Promise<void> {
return del<void>(`/api-keys/${id}`); return del<void>(`/api/v1/api-keys/${id}`);
} }

View File

@ -457,73 +457,86 @@ export interface CsvRateSearchRequest {
destination: string; destination: string;
volumeCBM: number; volumeCBM: number;
weightKG: number; weightKG: number;
palletCount?: number;
containerType?: string; containerType?: string;
hasDangerousGoods?: boolean; hasDangerousGoods?: boolean;
requiresSpecialHandling?: boolean;
requiresTailgate?: boolean;
requiresStraps?: boolean;
requiresThermalCover?: boolean;
hasRegulatedProducts?: boolean;
requiresAppointment?: boolean;
filters?: RateSearchFilters; filters?: RateSearchFilters;
} }
export interface RateSearchFilters { export interface RateSearchFilters {
companies?: string[]; companies?: string[];
minVolumeCBM?: number; onlyDirect?: boolean;
maxVolumeCBM?: number; excludeNonDgRoutes?: boolean;
minWeightKG?: number;
maxWeightKG?: number;
palletCount?: number;
minPrice?: number; minPrice?: number;
maxPrice?: number; maxPrice?: number;
currency?: 'USD' | 'EUR'; currency?: 'USD' | 'EUR';
minTransitDays?: number; minTransitDays?: number;
maxTransitDays?: number; maxTransitDays?: number;
containerTypes?: string[]; containerTypes?: string[];
onlyAllInPrices?: boolean; departureDate?: string;
departureDate?: Date; serviceLevels?: ('RAPID' | 'STANDARD' | 'ECONOMIC')[];
} }
export interface SurchargeItem { export type DgSurchargeStatus = 'computed' | 'on_request' | 'not_accepted';
code: string;
description: string; export interface FobBreakdown {
amount: number; documentation: number;
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE'; isps: number;
handling: number;
solas: number;
customs: number;
ams_aci: number;
isf5: number;
dgAdmin: number;
} }
export interface PriceBreakdown { export interface PriceBreakdown {
basePrice: number; freightCharge: number;
volumeCharge: number; freightCurrency: string;
weightCharge: number; fobFixed: number;
palletCharge: number; fobHandling: number;
surcharges: SurchargeItem[]; fobDG: number;
totalSurcharges: number; fobCurrency: string;
totalPrice: number; fobBreakdown: FobBreakdown;
currency: string; dgSurchargeAmount: number | null;
dgSurchargeCurrency: string;
dgSurchargeStatus: DgSurchargeStatus;
totalFreight: number;
totalFob: number;
totalPriceForSorting: number;
primaryCurrency: string;
} }
export interface CsvRateResult { export interface CsvRateSearchResult {
companyName: string; companyName: string;
companyEmail: string; companyEmail: string;
originCFS: string;
origin: string; origin: string;
portOfLoading: string;
routing: string;
destinationCFS: string;
destination: string; destination: string;
destinationCountry: string;
containerType: string; containerType: string;
priceUSD: number;
priceEUR: number;
primaryCurrency: string;
priceBreakdown: PriceBreakdown; priceBreakdown: PriceBreakdown;
hasSurcharges: boolean; frequency: string;
surchargeDetails: string | null;
transitDays: number; transitDays: number;
validUntil: string; validUntil: string;
source: 'CSV' | 'API'; dgAccepted: boolean;
dgSurchargeStatus: DgSurchargeStatus;
remarks: string;
source: string;
matchScore: number; matchScore: number;
serviceLevel?: 'RAPID' | 'STANDARD' | 'ECONOMIC';
priceMultiplier?: number;
originalTransitDays?: number;
adjustedTransitDays?: number;
} }
/** @deprecated Use CsvRateSearchResult */
export type CsvRateResult = CsvRateSearchResult;
export interface CsvRateSearchResponse { export interface CsvRateSearchResponse {
results: CsvRateResult[]; results: CsvRateSearchResult[];
totalResults: number; totalResults: number;
searchedFiles: string[]; searchedFiles: string[];
searchedAt: string; searchedAt: string;

View File

@ -1,74 +1,18 @@
/** import type { CsvRateSearchResult, CsvRateSearchResponse, RateSearchFilters } from './rates';
* Rate Search Filters Types
*
* TypeScript types for advanced rate search filters
* Matches backend DTOs
*/
export interface RateSearchFilters { // Re-export unified types
// Company filters export type { RateSearchFilters, CsvRateSearchResult as CsvRateResult, CsvRateSearchResponse };
companies?: string[];
// Volume/Weight filters
minVolumeCBM?: number;
maxVolumeCBM?: number;
minWeightKG?: number;
maxWeightKG?: number;
palletCount?: number;
// Price filters
minPrice?: number;
maxPrice?: number;
currency?: 'USD' | 'EUR';
// Transit filters
minTransitDays?: number;
maxTransitDays?: number;
// Container type filters
containerTypes?: string[];
// Surcharge filters
onlyAllInPrices?: boolean;
// Date filters
departureDate?: string; // ISO date string
}
export interface CsvRateSearchRequest { export interface CsvRateSearchRequest {
origin: string; origin: string;
destination: string; destination: string;
volumeCBM: number; volumeCBM: number;
weightKG: number; weightKG: number;
palletCount?: number;
containerType?: string; containerType?: string;
hasDangerousGoods?: boolean;
filters?: RateSearchFilters; filters?: RateSearchFilters;
} }
export interface CsvRateResult {
companyName: string;
origin: string;
destination: string;
containerType: string;
priceUSD: number;
priceEUR: number;
primaryCurrency: string;
hasSurcharges: boolean;
surchargeDetails: string | null;
transitDays: number;
validUntil: string;
source: 'CSV' | 'API';
matchScore: number;
}
export interface CsvRateSearchResponse {
results: CsvRateResult[];
totalResults: number;
searchedFiles: string[];
searchedAt: string;
appliedFilters: RateSearchFilters;
}
export interface AvailableCompanies { export interface AvailableCompanies {
companies: string[]; companies: string[];
total: number; total: number;

View File

@ -1,92 +1,95 @@
/** export type ServiceLevel = 'RAPID' | 'STANDARD' | 'ECONOMIC';
* Rate Search Types export type DgSurchargeStatus = 'computed' | 'on_request' | 'not_accepted';
*
* TypeScript types for rate search functionality export interface FobBreakdown {
*/ documentation: number;
isps: number;
handling: number;
solas: number;
customs: number;
ams_aci: number;
isf5: number;
dgAdmin: number;
}
export interface PriceBreakdown {
// Freight (in freightCurrency)
freightCharge: number;
freightCurrency: string;
// FOB (in fobCurrency)
fobFixed: number;
fobHandling: number;
fobDG: number;
fobCurrency: string;
fobBreakdown: FobBreakdown;
// DG surcharge
dgSurchargeAmount: number | null;
dgSurchargeCurrency: string;
dgSurchargeStatus: DgSurchargeStatus;
// Totals
totalFreight: number;
totalFob: number;
totalPriceForSorting: number; // naive sum for sorting
primaryCurrency: string;
}
/**
* CSV Rate Search Request
*/
export interface CsvRateSearchRequest { export interface CsvRateSearchRequest {
origin: string; origin: string;
destination: string; destination: string;
volumeCBM: number; volumeCBM: number;
weightKG: number; weightKG: number;
palletCount?: number;
containerType?: string; containerType?: string;
hasDangerousGoods?: boolean; hasDangerousGoods?: boolean;
requiresSpecialHandling?: boolean; filters?: RateSearchFilters;
requiresTailgate?: boolean;
requiresStraps?: boolean;
requiresThermalCover?: boolean;
hasRegulatedProducts?: boolean;
requiresAppointment?: boolean;
} }
/** export interface RateSearchFilters {
* Surcharge Details companies?: string[];
*/ onlyDirect?: boolean;
export interface Surcharge { excludeNonDgRoutes?: boolean;
code: string; minPrice?: number;
description: string; maxPrice?: number;
amount: number; currency?: 'USD' | 'EUR';
type: string; minTransitDays?: number;
maxTransitDays?: number;
containerTypes?: string[];
departureDate?: string;
serviceLevels?: ServiceLevel[];
} }
/**
* Price Breakdown
*/
export interface PriceBreakdown {
basePrice: number;
volumeCharge: number;
weightCharge: number;
palletCharge: number;
surcharges: Surcharge[];
totalSurcharges: number;
totalPrice: number;
currency: string;
}
/**
* Service Level for Rate Offers
*/
export type ServiceLevel = 'RAPID' | 'STANDARD' | 'ECONOMIC';
/**
* CSV Rate Search Result
*/
export interface CsvRateSearchResult { export interface CsvRateSearchResult {
companyName: string; companyName: string;
companyEmail: string; companyEmail: string;
originCFS: string;
origin: string; origin: string;
portOfLoading: string;
routing: string;
destinationCFS: string;
destination: string; destination: string;
destinationCountry: string;
containerType: string; containerType: string;
priceUSD: number;
priceEUR: number;
primaryCurrency: string;
priceBreakdown: PriceBreakdown; priceBreakdown: PriceBreakdown;
hasSurcharges: boolean; frequency: string;
surchargeDetails: string | null;
transitDays: number; transitDays: number;
validUntil: string; validUntil: string;
dgAccepted: boolean;
dgSurchargeStatus: DgSurchargeStatus;
remarks: string;
source: string; source: string;
matchScore: number; matchScore: number;
// Service level offer fields (only present when using search-csv-offers endpoint)
serviceLevel?: ServiceLevel; serviceLevel?: ServiceLevel;
originalPrice?: { priceMultiplier?: number;
usd: number;
eur: number;
};
originalTransitDays?: number; originalTransitDays?: number;
adjustedTransitDays?: number;
} }
/**
* CSV Rate Search Response
*/
export interface CsvRateSearchResponse { export interface CsvRateSearchResponse {
results: CsvRateSearchResult[]; results: CsvRateSearchResult[];
totalResults: number; totalResults: number;
searchedFiles: string[]; searchedFiles: string[];
searchedAt: Date; searchedAt: string;
appliedFilters: Record<string, any>; appliedFilters: Record<string, any>;
} }

View File

@ -82,6 +82,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
env_file:
- ./apps/backend/.env
environment: environment:
NODE_ENV: development NODE_ENV: development
LOG_FORMAT: json LOG_FORMAT: json