Compare commits
No commits in common. "f5eaa4e083285d83a04f47b446d37d9df0147d8c" and "84790e0c68abd8e9a408cd48a42dd6809a2ee3e7" have entirely different histories.
f5eaa4e083
...
84790e0c68
@ -166,16 +166,27 @@ 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,
|
||||||
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
|
||||||
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
||||||
|
|
||||||
|
// Service requirements for detailed pricing
|
||||||
|
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
||||||
|
requiresSpecialHandling: dto.requiresSpecialHandling ?? false,
|
||||||
|
requiresTailgate: dto.requiresTailgate ?? false,
|
||||||
|
requiresStraps: dto.requiresStraps ?? false,
|
||||||
|
requiresThermalCover: dto.requiresThermalCover ?? false,
|
||||||
|
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
|
||||||
|
requiresAppointment: dto.requiresAppointment ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Execute CSV rate search
|
||||||
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
|
||||||
@ -230,16 +241,27 @@ 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,
|
||||||
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
|
||||||
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
||||||
|
|
||||||
|
// Service requirements for detailed pricing
|
||||||
|
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
||||||
|
requiresSpecialHandling: dto.requiresSpecialHandling ?? false,
|
||||||
|
requiresTailgate: dto.requiresTailgate ?? false,
|
||||||
|
requiresStraps: dto.requiresStraps ?? false,
|
||||||
|
requiresThermalCover: dto.requiresThermalCover ?? false,
|
||||||
|
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
|
||||||
|
requiresAppointment: dto.requiresAppointment ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Execute CSV rate search 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
|
||||||
|
|||||||
@ -11,192 +11,384 @@ 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({ description: 'Origin UN/LOCODE', example: 'FRFOS' })
|
@ApiProperty({
|
||||||
|
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({ description: 'Destination UN/LOCODE', example: 'CNSHA' })
|
@ApiProperty({
|
||||||
|
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({ description: 'Volume in cubic meters (CBM)', minimum: 0.01, example: 10.5 })
|
@ApiProperty({
|
||||||
|
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({ description: 'Weight in kilograms', minimum: 1, example: 2500 })
|
@ApiProperty({
|
||||||
|
description: 'Weight in kilograms',
|
||||||
|
minimum: 1,
|
||||||
|
example: 3500,
|
||||||
|
})
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
weightKG: number;
|
weightKG: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Container type filter', example: 'LCL' })
|
@ApiPropertyOptional({
|
||||||
|
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({ description: 'Cargo contains dangerous goods', example: false })
|
@ApiPropertyOptional({
|
||||||
@IsOptional()
|
description: 'Advanced filters for narrowing results',
|
||||||
@IsBoolean()
|
type: RateSearchFiltersDto,
|
||||||
hasDangerousGoods?: boolean;
|
})
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Advanced filters', type: RateSearchFiltersDto })
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => RateSearchFiltersDto)
|
@Type(() => RateSearchFiltersDto)
|
||||||
filters?: RateSearchFiltersDto;
|
filters?: RateSearchFiltersDto;
|
||||||
|
|
||||||
|
// Service requirements for detailed price calculation
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Cargo contains dangerous goods (DG)',
|
||||||
|
example: true,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
hasDangerousGoods?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Requires special handling',
|
||||||
|
example: true,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresSpecialHandling?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Requires tailgate lift',
|
||||||
|
example: false,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresTailgate?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Requires securing straps',
|
||||||
|
example: true,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresStraps?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Requires thermal protection cover',
|
||||||
|
example: false,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresThermalCover?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Contains regulated products requiring special documentation',
|
||||||
|
example: false,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
hasRegulatedProducts?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Requires delivery appointment',
|
||||||
|
example: true,
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresAppointment?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV Rate Search Response DTO
|
||||||
|
*
|
||||||
|
* Response containing matching rates with calculated prices
|
||||||
|
*/
|
||||||
export class CsvRateSearchResponseDto {
|
export class CsvRateSearchResponseDto {
|
||||||
@ApiProperty({ description: 'Array of matching rate results', type: [Object] })
|
@ApiProperty({
|
||||||
|
description: 'Array of matching rate results',
|
||||||
|
type: [Object], // Will be replaced with RateResultDto
|
||||||
|
})
|
||||||
results: CsvRateResultDto[];
|
results: CsvRateResultDto[];
|
||||||
|
|
||||||
@ApiProperty({ description: 'Total number of results', example: 12 })
|
@ApiProperty({
|
||||||
|
description: 'Total number of results found',
|
||||||
|
example: 15,
|
||||||
|
})
|
||||||
totalResults: number;
|
totalResults: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'CSV files searched', type: [String] })
|
@ApiProperty({
|
||||||
|
description: 'CSV files that were searched',
|
||||||
|
type: [String],
|
||||||
|
example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'],
|
||||||
|
})
|
||||||
searchedFiles: string[];
|
searchedFiles: string[];
|
||||||
|
|
||||||
@ApiProperty({ description: 'Timestamp of search', example: '2026-05-11T10:30:00Z' })
|
@ApiProperty({
|
||||||
|
description: 'Timestamp when search was executed',
|
||||||
|
example: '2025-10-23T10:30:00Z',
|
||||||
|
})
|
||||||
searchedAt: Date;
|
searchedAt: Date;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Applied filters' })
|
@ApiProperty({
|
||||||
|
description: 'Filters that were applied to the search',
|
||||||
|
type: RateSearchFiltersDto,
|
||||||
|
})
|
||||||
appliedFilters: RateSearchFiltersDto;
|
appliedFilters: RateSearchFiltersDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FobBreakdownDto {
|
/**
|
||||||
documentation: number;
|
* Surcharge Item DTO
|
||||||
isps: number;
|
*/
|
||||||
handling: number;
|
export class SurchargeItemDto {
|
||||||
solas: number;
|
@ApiProperty({
|
||||||
customs: number;
|
description: 'Surcharge code',
|
||||||
ams_aci: number;
|
example: 'DG_FEE',
|
||||||
isf5: number;
|
|
||||||
dgAdmin: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PriceBreakdownDto {
|
|
||||||
@ApiProperty({ description: 'Freight charge', example: 420.0 })
|
|
||||||
freightCharge: number;
|
|
||||||
|
|
||||||
@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,
|
|
||||||
})
|
})
|
||||||
dgSurchargeAmount: number | null;
|
code: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'DG surcharge currency', example: 'EUR' })
|
|
||||||
dgSurchargeCurrency: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'DG surcharge status',
|
description: 'Surcharge description',
|
||||||
enum: ['computed', 'on_request', 'not_accepted'],
|
example: 'Dangerous goods fee',
|
||||||
example: 'computed',
|
|
||||||
})
|
})
|
||||||
dgSurchargeStatus: string;
|
description: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Total freight in freightCurrency', example: 420.0 })
|
@ApiProperty({
|
||||||
totalFreight: number;
|
description: 'Surcharge amount in currency',
|
||||||
|
example: 65.0,
|
||||||
|
})
|
||||||
|
amount: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Total FOB in fobCurrency', example: 245 })
|
@ApiProperty({
|
||||||
totalFob: number;
|
description: 'Type of surcharge calculation',
|
||||||
|
enum: ['FIXED', 'PER_UNIT', 'PERCENTAGE'],
|
||||||
@ApiProperty({ description: 'Sum for sorting (currency-naive)', example: 665.0 })
|
example: 'FIXED',
|
||||||
totalPriceForSorting: number;
|
})
|
||||||
|
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
|
||||||
@ApiProperty({ description: 'Primary currency', example: 'USD' })
|
|
||||||
primaryCurrency: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price Breakdown DTO
|
||||||
|
*/
|
||||||
|
export class PriceBreakdownDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Base price before any charges',
|
||||||
|
example: 0,
|
||||||
|
})
|
||||||
|
basePrice: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Charge based on volume (CBM)',
|
||||||
|
example: 150.0,
|
||||||
|
})
|
||||||
|
volumeCharge: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Charge based on weight (KG)',
|
||||||
|
example: 25.0,
|
||||||
|
})
|
||||||
|
weightCharge: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Charge for pallets',
|
||||||
|
example: 125.0,
|
||||||
|
})
|
||||||
|
palletCharge: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'List of all surcharges',
|
||||||
|
type: [SurchargeItemDto],
|
||||||
|
})
|
||||||
|
surcharges: SurchargeItemDto[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Total of all surcharges',
|
||||||
|
example: 242.0,
|
||||||
|
})
|
||||||
|
totalSurcharges: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Total price including all charges',
|
||||||
|
example: 542.0,
|
||||||
|
})
|
||||||
|
totalPrice: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Currency of the pricing',
|
||||||
|
enum: ['USD', 'EUR'],
|
||||||
|
example: 'USD',
|
||||||
|
})
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single CSV Rate Result DTO
|
||||||
|
*/
|
||||||
export class CsvRateResultDto {
|
export class CsvRateResultDto {
|
||||||
@ApiProperty({ example: 'SSC Consolidation' })
|
@ApiProperty({
|
||||||
|
description: 'Company name',
|
||||||
|
example: 'SSC Consolidation',
|
||||||
|
})
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'bookings@ssc.com' })
|
@ApiProperty({
|
||||||
|
description: 'Company email for booking requests',
|
||||||
|
example: 'bookings@sscconsolidation.com',
|
||||||
|
})
|
||||||
companyEmail: string;
|
companyEmail: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Origin CFS name', example: 'Fos Sur Mer' })
|
@ApiProperty({
|
||||||
originCFS: string;
|
description: 'Origin port code',
|
||||||
|
example: 'NLRTM',
|
||||||
@ApiProperty({ description: 'Origin UN/LOCODE', example: 'FRFOS' })
|
})
|
||||||
origin: string;
|
origin: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Port of loading', example: 'FOS SUR MER' })
|
@ApiProperty({
|
||||||
portOfLoading: string;
|
description: 'Destination port code',
|
||||||
|
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({ description: 'Destination country', example: 'China' })
|
@ApiProperty({
|
||||||
destinationCountry: string;
|
description: 'Container type',
|
||||||
|
example: 'LCL',
|
||||||
@ApiProperty({ example: 'LCL' })
|
})
|
||||||
containerType: string;
|
containerType: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Detailed price breakdown', type: PriceBreakdownDto })
|
@ApiProperty({
|
||||||
|
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({ description: 'Departure frequency', example: 'Weekly' })
|
@ApiProperty({
|
||||||
frequency: string;
|
description: 'Whether this rate has separate surcharges',
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
hasSurcharges: boolean;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Transit time (adjusted if service level)', example: 28 })
|
@ApiProperty({
|
||||||
|
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({ description: 'Rate validity end date', example: '2026-12-31' })
|
@ApiProperty({
|
||||||
|
description: 'Rate validity end date',
|
||||||
|
example: '2025-12-31',
|
||||||
|
})
|
||||||
validUntil: string;
|
validUntil: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Whether DG cargo is accepted', example: true })
|
@ApiProperty({
|
||||||
dgAccepted: boolean;
|
description: 'Source of the rate',
|
||||||
|
enum: ['CSV', 'API'],
|
||||||
|
example: 'CSV',
|
||||||
|
})
|
||||||
|
source: 'CSV' | 'API';
|
||||||
|
|
||||||
@ApiProperty({ description: 'DG surcharge status', example: 'computed' })
|
@ApiProperty({
|
||||||
dgSurchargeStatus: string;
|
description: 'Match score (0-100) indicating how well this rate matches the search',
|
||||||
|
minimum: 0,
|
||||||
@ApiProperty({ description: 'Internal remarks', example: 'GR1/GR2' })
|
maximum: 100,
|
||||||
remarks: string;
|
example: 95,
|
||||||
|
})
|
||||||
@ApiProperty({ example: 'CSV' })
|
|
||||||
source: 'CSV';
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Match score 0-100', example: 95 })
|
|
||||||
matchScore: number;
|
matchScore: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['RAPID', 'STANDARD', 'ECONOMIC'] })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Service level (only present when using search-csv-offers endpoint)',
|
||||||
|
enum: ['RAPID', 'STANDARD', 'ECONOMIC'],
|
||||||
|
example: 'RAPID',
|
||||||
|
})
|
||||||
serviceLevel?: string;
|
serviceLevel?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Price multiplier for service level', example: 1.0 })
|
@ApiPropertyOptional({
|
||||||
priceMultiplier?: number;
|
description: 'Original price before service level adjustment',
|
||||||
|
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: 28,
|
example: 20,
|
||||||
})
|
})
|
||||||
originalTransitDays?: number;
|
originalTransitDays?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,9 +10,15 @@ 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',
|
description: 'List of company names to include in search',
|
||||||
type: [String],
|
type: [String],
|
||||||
example: ['SSC Consolidation', 'ECU Worldwide'],
|
example: ['SSC Consolidation', 'ECU Worldwide'],
|
||||||
})
|
})
|
||||||
@ -22,25 +28,59 @@ export class RateSearchFiltersDto {
|
|||||||
companies?: string[];
|
companies?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Only show "Direct" routing (exclude transhipment)',
|
description: 'Minimum volume in CBM (cubic meters)',
|
||||||
example: false,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
onlyDirect?: boolean;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Exclude routes where DG is not accepted',
|
|
||||||
example: false,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
excludeNonDgRoutes?: boolean;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Minimum price (totalPriceForSorting)',
|
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 500,
|
example: 1,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
minVolumeCBM?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Maximum volume in CBM (cubic meters)',
|
||||||
|
minimum: 0,
|
||||||
|
example: 100,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
maxVolumeCBM?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Minimum weight in kilograms',
|
||||||
|
minimum: 0,
|
||||||
|
example: 100,
|
||||||
|
})
|
||||||
|
@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()
|
||||||
@ -48,9 +88,9 @@ export class RateSearchFiltersDto {
|
|||||||
minPrice?: number;
|
minPrice?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Maximum price (totalPriceForSorting)',
|
description: 'Maximum price in selected currency',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 3000,
|
example: 5000,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@ -70,7 +110,7 @@ export class RateSearchFiltersDto {
|
|||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Maximum transit time in days',
|
description: 'Maximum transit time in days',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
example: 45,
|
example: 40,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@ -80,7 +120,7 @@ export class RateSearchFiltersDto {
|
|||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Container types to filter by',
|
description: 'Container types to filter by',
|
||||||
type: [String],
|
type: [String],
|
||||||
example: ['LCL'],
|
example: ['LCL', '20DRY', '40HC'],
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ -88,7 +128,7 @@ export class RateSearchFiltersDto {
|
|||||||
containerTypes?: string[];
|
containerTypes?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Preferred currency for price display',
|
description: 'Preferred currency for price filtering',
|
||||||
enum: ['USD', 'EUR'],
|
enum: ['USD', 'EUR'],
|
||||||
example: 'USD',
|
example: 'USD',
|
||||||
})
|
})
|
||||||
@ -96,9 +136,17 @@ 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: '2026-06-15',
|
example: '2025-06-15',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
|
|||||||
@ -1,10 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import { CsvRateResultDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
|
||||||
CsvRateResultDto,
|
|
||||||
CsvRateSearchResponseDto,
|
|
||||||
PriceBreakdownDto,
|
|
||||||
FobBreakdownDto,
|
|
||||||
} from '../dto/csv-rate-search.dto';
|
|
||||||
import {
|
import {
|
||||||
CsvRateSearchOutput,
|
CsvRateSearchOutput,
|
||||||
CsvRateSearchResult,
|
CsvRateSearchResult,
|
||||||
@ -14,92 +9,100 @@ 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) return undefined;
|
if (!dto) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
companies: dto.companies,
|
companies: dto.companies,
|
||||||
onlyDirect: dto.onlyDirect,
|
minVolumeCBM: dto.minVolumeCBM,
|
||||||
excludeNonDgRoutes: dto.excludeNonDgRoutes,
|
maxVolumeCBM: dto.maxVolumeCBM,
|
||||||
|
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,
|
||||||
originCFS: rate.originCFS,
|
origin: rate.origin.getValue(),
|
||||||
origin: rate.originCode.getValue(),
|
destination: rate.destination.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(),
|
||||||
priceBreakdown,
|
priceUSD: result.calculatedPrice.usd,
|
||||||
frequency: rate.frequency,
|
priceEUR: result.calculatedPrice.eur,
|
||||||
|
primaryCurrency: result.calculatedPrice.primaryCurrency,
|
||||||
|
priceBreakdown: {
|
||||||
|
basePrice: result.priceBreakdown.basePrice,
|
||||||
|
volumeCharge: result.priceBreakdown.volumeCharge,
|
||||||
|
weightCharge: result.priceBreakdown.weightCharge,
|
||||||
|
palletCharge: result.priceBreakdown.palletCharge,
|
||||||
|
surcharges: result.priceBreakdown.surcharges.map(s => ({
|
||||||
|
code: s.code,
|
||||||
|
description: s.description,
|
||||||
|
amount: s.amount,
|
||||||
|
type: s.type,
|
||||||
|
})),
|
||||||
|
totalSurcharges: result.priceBreakdown.totalSurcharges,
|
||||||
|
totalPrice: result.priceBreakdown.totalPrice,
|
||||||
|
currency: result.priceBreakdown.currency,
|
||||||
|
},
|
||||||
|
hasSurcharges: rate.hasSurcharges(),
|
||||||
|
surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null,
|
||||||
|
// 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,
|
||||||
priceMultiplier: result.priceMultiplier,
|
originalPrice: result.originalPrice,
|
||||||
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(r => this.mapSearchResultToDto(r)),
|
results: output.results.map(result => this.mapSearchResultToDto(result)),
|
||||||
totalResults: output.totalResults,
|
totalResults: output.totalResults,
|
||||||
searchedFiles: output.searchedFiles,
|
searchedFiles: output.searchedFiles,
|
||||||
searchedAt: output.searchedAt,
|
searchedAt: output.searchedAt,
|
||||||
appliedFilters: output.appliedFilters as any,
|
appliedFilters: output.appliedFilters as any, // Already matches DTO structure
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map ORM entity to DTO
|
||||||
|
*/
|
||||||
mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto {
|
mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto {
|
||||||
return {
|
return {
|
||||||
id: entity.id,
|
id: entity.id,
|
||||||
@ -115,7 +118,10 @@ export class CsvRateMapper {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map multiple config entities to DTOs
|
||||||
|
*/
|
||||||
mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] {
|
mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] {
|
||||||
return entities.map(e => this.mapConfigEntityToDto(e));
|
return entities.map(entity => this.mapConfigEntityToDto(entity));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,69 +1,60 @@
|
|||||||
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';
|
/**
|
||||||
export type HandlingUnit = 'W' | 'UP'; // W = tonne revenue (max CBM/T), UP = per CBM
|
* Volume Range - Valid range for CBM
|
||||||
export type FrequencyType = 'Weekly' | 'Bi-Weekly' | 'Bi-Monthly' | 'Monthly';
|
*/
|
||||||
|
export interface VolumeRange {
|
||||||
export interface FreightPricing {
|
minCBM: number;
|
||||||
freightCurrency: string;
|
maxCBM: number;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CsvRate — Shipping rate from a consolidator CSV file.
|
* Weight Range - Valid range for KG
|
||||||
|
*/
|
||||||
|
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:
|
||||||
* - Route matching uses originCode + destinationCode (UN/LOCODE)
|
* - Price is calculated as: max(volumeCBM * pricePerCBM, weightKG * pricePerKG) + surcharges
|
||||||
* - Price = max(freightRatePerCBM×V, freightMinimum) + FOB fixed + handling
|
* - Rate must be valid (within validity period) to be used
|
||||||
* - FOB and freight may be in different currencies
|
* - Volume and weight must be within specified ranges
|
||||||
* - 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,
|
||||||
// Route geography
|
public readonly origin: PortCode,
|
||||||
public readonly originCFS: string,
|
public readonly destination: PortCode,
|
||||||
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,
|
||||||
// Pricing
|
public readonly volumeRange: VolumeRange,
|
||||||
public readonly freight: FreightPricing,
|
public readonly weightRange: WeightRange,
|
||||||
public readonly fob: FobCharges,
|
public readonly palletCount: number,
|
||||||
public readonly dgSurcharge: DgSurchargeInfo,
|
public readonly pricing: RatePricing,
|
||||||
// Metadata
|
public readonly currency: string, // Primary currency (USD or EUR)
|
||||||
public readonly remarks: string,
|
public readonly surcharges: SurchargeCollection,
|
||||||
public readonly frequency: FrequencyType,
|
|
||||||
public readonly transitDays: number,
|
public readonly transitDays: number,
|
||||||
public readonly validity: DateRange
|
public readonly validity: DateRange
|
||||||
) {
|
) {
|
||||||
@ -71,56 +62,178 @@ export class CsvRate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private validate(): void {
|
private validate(): void {
|
||||||
if (!this.companyName?.trim()) throw new Error('Company name is required');
|
if (!this.companyName || this.companyName.trim().length === 0) {
|
||||||
if (!this.companyEmail?.trim()) throw new Error('Company email is required');
|
throw new Error('Company name 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.originCode.equals(origin) && this.destinationCode.equals(destination);
|
return this.origin.equals(origin) && this.destination.equals(destination);
|
||||||
}
|
}
|
||||||
|
|
||||||
isDgAccepted(): boolean {
|
/**
|
||||||
return this.dgSurcharge.dgSurchargeRate !== 'NOT ACCEPTED';
|
* Check if rate has separate surcharges
|
||||||
|
*/
|
||||||
|
hasSurcharges(): boolean {
|
||||||
|
return !this.surcharges.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
isDgOnRequest(): boolean {
|
/**
|
||||||
return this.dgSurcharge.dgSurchargeRate === 'ON REQUEST';
|
* Get surcharge details as formatted string
|
||||||
|
*/
|
||||||
|
getSurchargeDetails(): string {
|
||||||
|
return this.surcharges.getDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
isDirectRoute(): boolean {
|
/**
|
||||||
return this.routing.trim().toLowerCase() === 'direct';
|
* Check if this is an "all-in" rate (no separate surcharges)
|
||||||
}
|
*/
|
||||||
|
isAllInPrice(): boolean {
|
||||||
getFrequencyScore(): number {
|
return this.surcharges.isEmpty();
|
||||||
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.originCode.getValue()} → ${this.destinationCode.getValue()}`;
|
return `${this.origin.getValue()} → ${this.destination.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()})`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,73 +1,160 @@
|
|||||||
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 };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters for narrowing CSV rate search results.
|
* Advanced Rate Search Filters
|
||||||
* 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 {
|
||||||
companies?: string[];
|
// Company filters
|
||||||
|
companies?: string[]; // List of company names to include
|
||||||
|
|
||||||
// Price filter (applied to totalPriceForSorting)
|
// Volume/Weight filters
|
||||||
|
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';
|
currency?: 'USD' | 'EUR'; // Preferred currency for filtering
|
||||||
|
|
||||||
// Transit filter
|
// Transit filters
|
||||||
minTransitDays?: number;
|
minTransitDays?: number;
|
||||||
maxTransitDays?: number;
|
maxTransitDays?: number;
|
||||||
|
|
||||||
// Route filter
|
// Container type filters
|
||||||
onlyDirect?: boolean; // Only show "Direct" routing
|
containerTypes?: string[]; // e.g., ['LCL', '20DRY', '40HC']
|
||||||
|
|
||||||
// Container type filter
|
// Surcharge filters
|
||||||
containerTypes?: string[];
|
onlyAllInPrices?: boolean; // Only show rates without separate surcharges
|
||||||
|
|
||||||
// Date filter
|
// Date filters
|
||||||
departureDate?: Date;
|
departureDate?: Date; // Filter by validity for specific date
|
||||||
|
|
||||||
// Service level filter (for offers endpoint)
|
// Service level filter
|
||||||
serviceLevels?: ServiceLevel[];
|
serviceLevels?: ServiceLevel[]; // Filter by service level (RAPID, STANDARD, ECONOMIC)
|
||||||
|
|
||||||
// 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; // UN/LOCODE
|
origin: string; // Port code (UN/LOCODE)
|
||||||
destination: string; // UN/LOCODE
|
destination: string; // Port code (UN/LOCODE)
|
||||||
volumeCBM: number;
|
volumeCBM: number; // Volume in cubic meters
|
||||||
weightKG: number;
|
weightKG: number; // Weight in kilograms
|
||||||
containerType?: string;
|
palletCount?: number; // Number of pallets (0 if none)
|
||||||
|
containerType?: string; // Optional container type filter
|
||||||
|
filters?: RateSearchFilters; // Advanced filters
|
||||||
|
|
||||||
|
// Service requirements for price calculation
|
||||||
hasDangerousGoods?: boolean;
|
hasDangerousGoods?: boolean;
|
||||||
filters?: RateSearchFilters;
|
requiresSpecialHandling?: boolean;
|
||||||
|
requiresTailgate?: boolean;
|
||||||
|
requiresStraps?: boolean;
|
||||||
|
requiresThermalCover?: boolean;
|
||||||
|
hasRegulatedProducts?: boolean;
|
||||||
|
requiresAppointment?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surcharge Item - Individual fee or charge
|
||||||
|
*/
|
||||||
|
export interface SurchargeItem {
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price Breakdown - Detailed pricing calculation
|
||||||
|
*/
|
||||||
|
export interface PriceBreakdown {
|
||||||
|
basePrice: number;
|
||||||
|
volumeCharge: number;
|
||||||
|
weightCharge: number;
|
||||||
|
palletCharge: number;
|
||||||
|
surcharges: SurchargeItem[];
|
||||||
|
totalSurcharges: number;
|
||||||
|
totalPrice: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSV Rate Search Result
|
||||||
|
*
|
||||||
|
* Single rate result with calculated price
|
||||||
|
*/
|
||||||
export interface CsvRateSearchResult {
|
export interface CsvRateSearchResult {
|
||||||
rate: CsvRate;
|
rate: CsvRate;
|
||||||
priceBreakdown: PriceBreakdown;
|
calculatedPrice: {
|
||||||
|
usd: number;
|
||||||
|
eur: number;
|
||||||
|
primaryCurrency: string;
|
||||||
|
};
|
||||||
|
priceBreakdown: PriceBreakdown; // Detailed price calculation
|
||||||
source: 'CSV';
|
source: 'CSV';
|
||||||
matchScore: number;
|
matchScore: number; // 0-100, how well it matches filters
|
||||||
serviceLevel?: ServiceLevel;
|
serviceLevel?: ServiceLevel; // Service level (RAPID, STANDARD, ECONOMIC) if offers are generated
|
||||||
priceMultiplier?: number;
|
originalPrice?: {
|
||||||
originalTransitDays?: number;
|
usd: number;
|
||||||
adjustedTransitDays?: number;
|
eur: 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[];
|
searchedFiles: string[]; // CSV files searched
|
||||||
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[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,152 +3,217 @@ import { CsvRate } from '../entities/csv-rate.entity';
|
|||||||
export interface PriceCalculationParams {
|
export interface PriceCalculationParams {
|
||||||
volumeCBM: number;
|
volumeCBM: number;
|
||||||
weightKG: number;
|
weightKG: number;
|
||||||
hasDangerousGoods?: boolean;
|
palletCount: number;
|
||||||
|
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 {
|
||||||
// Freight (in freightCurrency)
|
basePrice: number;
|
||||||
freightCharge: number;
|
volumeCharge: number;
|
||||||
freightCurrency: string;
|
weightCharge: number;
|
||||||
|
palletCharge: number;
|
||||||
|
surcharges: SurchargeItem[];
|
||||||
|
totalSurcharges: number;
|
||||||
|
totalPrice: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
// FOB charges (in fobCurrency)
|
export interface SurchargeItem {
|
||||||
fobFixed: number; // doc + ISPS + solas + customs + AMS_ACI + ISF5
|
code: string;
|
||||||
fobHandling: number;
|
description: string;
|
||||||
fobDG: number; // fobDGAdmin only if DG
|
amount: number;
|
||||||
fobCurrency: string;
|
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates price for a CSV rate given volume and weight.
|
* Service de calcul de prix pour les tarifs CSV
|
||||||
*
|
* 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 {
|
||||||
const V = params.volumeCBM;
|
// 1. Prix de base
|
||||||
const W = params.weightKG / 1000; // convert KG → tonnes for W unit
|
const basePrice = rate.pricing.basePriceUSD.getAmount();
|
||||||
const isDG = params.hasDangerousGoods ?? false;
|
|
||||||
|
|
||||||
// 1. Freight charge
|
// 2. Frais au volume (USD par CBM)
|
||||||
const freightCharge =
|
const volumeCharge = rate.pricing.pricePerCBM * params.volumeCBM;
|
||||||
rate.freight.freightRatePerCBM > 0
|
|
||||||
? Math.max(rate.freight.freightRatePerCBM * V, rate.freight.freightMinimum)
|
|
||||||
: rate.freight.freightMinimum;
|
|
||||||
|
|
||||||
// 2. Handling — "W" = tonne revenue (max of CBM and tonnes), "UP" = per CBM
|
// 3. Frais au poids (USD par KG)
|
||||||
const handlingBase = rate.fob.fobHandlingUnit === 'W' ? Math.max(V, W) : V;
|
const weightCharge = rate.pricing.pricePerKG * params.weightKG;
|
||||||
const fobHandling = Math.max(rate.fob.fobHandling * handlingBase, rate.fob.fobHandlingMinimum);
|
|
||||||
|
|
||||||
// 3. FOB fixed charges
|
// 4. Frais de palettes (25 USD par palette)
|
||||||
const fobFixed =
|
const palletCharge = params.palletCount * 25;
|
||||||
rate.fob.fobDocumentation +
|
|
||||||
rate.fob.fobISPS +
|
|
||||||
rate.fob.fobSolas +
|
|
||||||
rate.fob.fobCustoms +
|
|
||||||
rate.fob.fobAMS_ACI +
|
|
||||||
rate.fob.fobISF5;
|
|
||||||
|
|
||||||
// 4. DG admin (FOB currency, only if DG)
|
// 5. Surcharges standard du CSV
|
||||||
const fobDG = isDG ? rate.fob.fobDGAdmin : 0;
|
const standardSurcharges = this.parseStandardSurcharges(rate.getSurchargeDetails(), params);
|
||||||
|
|
||||||
// 5. DG surcharge (own currency, only if DG)
|
// 6. Surcharges additionnelles basées sur les services
|
||||||
let dgSurchargeAmount: number | null = null;
|
const additionalSurcharges = this.calculateAdditionalSurcharges(params);
|
||||||
let dgSurchargeStatus: DgSurchargeStatus = 'computed';
|
|
||||||
|
|
||||||
if (isDG) {
|
// 7. Total des surcharges
|
||||||
const dgRate = rate.dgSurcharge.dgSurchargeRate;
|
const allSurcharges = [...standardSurcharges, ...additionalSurcharges];
|
||||||
if (dgRate === 'NOT ACCEPTED') {
|
const totalSurcharges = allSurcharges.reduce((sum, s) => sum + s.amount, 0);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Total FOB (in fobCurrency)
|
// 8. Prix total
|
||||||
const totalFob = fobFixed + fobHandling + fobDG + (dgSurchargeAmount ?? 0);
|
const totalPrice = basePrice + volumeCharge + weightCharge + palletCharge + totalSurcharges;
|
||||||
|
|
||||||
// 7. Naive sum for sorting (ignores currency differences)
|
|
||||||
const totalPriceForSorting = freightCharge + totalFob;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
freightCharge: round2(freightCharge),
|
basePrice,
|
||||||
freightCurrency: rate.freight.freightCurrency,
|
volumeCharge,
|
||||||
fobFixed: round2(fobFixed),
|
weightCharge,
|
||||||
fobHandling: round2(fobHandling),
|
palletCharge,
|
||||||
fobDG: round2(fobDG),
|
surcharges: allSurcharges,
|
||||||
fobCurrency: rate.fob.fobCurrency,
|
totalSurcharges,
|
||||||
fobBreakdown: {
|
totalPrice: Math.round(totalPrice * 100) / 100, // Arrondi à 2 décimales
|
||||||
documentation: rate.fob.fobDocumentation,
|
currency: rate.currency || 'USD',
|
||||||
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 [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function round2(n: number): number {
|
const surcharges: SurchargeItem[] = [];
|
||||||
return Math.round(n * 100) / 100;
|
const items = surchargeDetails.split('|').map(s => s.trim());
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const match = item.match(/^([A-Z_]+):(\d+(?:\.\d+)?)\s*([WP%]?)$/);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const [, code, amountStr, type] = match;
|
||||||
|
let amount = parseFloat(amountStr);
|
||||||
|
let surchargeType: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE' = 'FIXED';
|
||||||
|
|
||||||
|
// Calcul selon le type
|
||||||
|
if (type === 'W') {
|
||||||
|
// Par poids (W = Weight)
|
||||||
|
amount = amount * params.weightKG;
|
||||||
|
surchargeType = 'PER_UNIT';
|
||||||
|
} else if (type === 'P') {
|
||||||
|
// Par palette
|
||||||
|
amount = amount * params.palletCount;
|
||||||
|
surchargeType = 'PER_UNIT';
|
||||||
|
} else if (type === '%') {
|
||||||
|
// Pourcentage (sera appliqué sur le total)
|
||||||
|
surchargeType = 'PERCENTAGE';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certaines surcharges ne s'appliquent que si certaines conditions sont remplies
|
||||||
|
if (code === 'DG_FEE' && !params.hasDangerousGoods) {
|
||||||
|
continue; // Skip DG fee si pas de marchandises dangereuses
|
||||||
|
}
|
||||||
|
|
||||||
|
surcharges.push({
|
||||||
|
code,
|
||||||
|
description: this.getSurchargeDescription(code),
|
||||||
|
amount: Math.round(amount * 100) / 100,
|
||||||
|
type: surchargeType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return surcharges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule les surcharges additionnelles basées sur les services demandés
|
||||||
|
*/
|
||||||
|
private calculateAdditionalSurcharges(params: PriceCalculationParams): SurchargeItem[] {
|
||||||
|
const surcharges: SurchargeItem[] = [];
|
||||||
|
|
||||||
|
if (params.requiresSpecialHandling) {
|
||||||
|
surcharges.push({
|
||||||
|
code: 'SPECIAL_HANDLING',
|
||||||
|
description: 'Manutention particulière',
|
||||||
|
amount: 75,
|
||||||
|
type: 'FIXED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.requiresTailgate) {
|
||||||
|
surcharges.push({
|
||||||
|
code: 'TAILGATE',
|
||||||
|
description: 'Hayon élévateur',
|
||||||
|
amount: 50,
|
||||||
|
type: 'FIXED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.requiresStraps) {
|
||||||
|
surcharges.push({
|
||||||
|
code: 'STRAPS',
|
||||||
|
description: 'Sangles de sécurité',
|
||||||
|
amount: 30,
|
||||||
|
type: 'FIXED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.requiresThermalCover) {
|
||||||
|
surcharges.push({
|
||||||
|
code: 'THERMAL_COVER',
|
||||||
|
description: 'Couverture thermique',
|
||||||
|
amount: 100,
|
||||||
|
type: 'FIXED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.hasRegulatedProducts) {
|
||||||
|
surcharges.push({
|
||||||
|
code: 'REGULATED_PRODUCTS',
|
||||||
|
description: 'Produits réglementés',
|
||||||
|
amount: 80,
|
||||||
|
type: 'FIXED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.requiresAppointment) {
|
||||||
|
surcharges.push({
|
||||||
|
code: 'APPOINTMENT',
|
||||||
|
description: 'Livraison sur rendez-vous',
|
||||||
|
amount: 40,
|
||||||
|
type: 'FIXED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return surcharges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne la description d'un code de surcharge standard
|
||||||
|
*/
|
||||||
|
private getSurchargeDescription(code: string): string {
|
||||||
|
const descriptions: Record<string, string> = {
|
||||||
|
DOC: 'Documentation fee',
|
||||||
|
ISPS: 'ISPS Security',
|
||||||
|
HANDLING: 'Handling charges',
|
||||||
|
SOLAS: 'SOLAS VGM',
|
||||||
|
CUSTOMS: 'Customs clearance',
|
||||||
|
AMS_ACI: 'AMS/ACI filing',
|
||||||
|
DG_FEE: 'Dangerous goods fee',
|
||||||
|
BAF: 'Bunker Adjustment Factor',
|
||||||
|
CAF: 'Currency Adjustment Factor',
|
||||||
|
THC: 'Terminal Handling Charges',
|
||||||
|
BL_FEE: 'Bill of Lading fee',
|
||||||
|
TELEX_RELEASE: 'Telex release',
|
||||||
|
ORIGIN_CHARGES: 'Origin charges',
|
||||||
|
DEST_CHARGES: 'Destination charges',
|
||||||
|
};
|
||||||
|
|
||||||
|
return descriptions[code] || code.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
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,
|
||||||
@ -10,8 +11,11 @@ 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 } from './rate-offer-generator.service';
|
import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config Metadata Interface (to avoid circular dependency)
|
||||||
|
*/
|
||||||
interface CsvRateConfig {
|
interface CsvRateConfig {
|
||||||
companyName: string;
|
companyName: string;
|
||||||
csvFilePath: string;
|
csvFilePath: string;
|
||||||
@ -21,10 +25,21 @@ 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;
|
||||||
@ -39,39 +54,63 @@ 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();
|
||||||
let matchingRates = this.filterByRoute(allRates, origin, destination);
|
|
||||||
|
|
||||||
|
// Apply route and volume matching
|
||||||
|
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, input);
|
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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),
|
matchScore: this.calculateMatchScore(rate, input),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
results.sort(
|
// Sort by total price (ascending)
|
||||||
(a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting
|
results.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice);
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
results,
|
results,
|
||||||
@ -83,67 +122,101 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search with service level offers — returns 3 variants per rate (ECONOMIC / STANDARD / RAPID).
|
* Execute CSV rate search with service level offers generation
|
||||||
* Price multipliers (0.85 / 1.0 / 1.2) are applied to totalPriceForSorting.
|
* Generates 3 offers (RAPID, STANDARD, ECONOMIC) for each matching rate
|
||||||
*/
|
*/
|
||||||
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();
|
||||||
let matchingRates = this.filterByRoute(allRates, origin, destination);
|
|
||||||
|
|
||||||
|
// Apply route and volume matching
|
||||||
|
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, input);
|
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
const multiplier = offer.priceMultiplier;
|
// Apply service level price adjustment to the total price
|
||||||
const adjustedBreakdown = {
|
const adjustedTotalPrice =
|
||||||
...priceBreakdown,
|
priceBreakdown.totalPrice *
|
||||||
freightCharge: round2(priceBreakdown.freightCharge * multiplier),
|
(offer.serviceLevel === ServiceLevel.RAPID
|
||||||
totalFreight: round2(priceBreakdown.totalFreight * multiplier),
|
? 1.2
|
||||||
totalFob: round2(priceBreakdown.totalFob * multiplier),
|
: offer.serviceLevel === ServiceLevel.ECONOMIC
|
||||||
totalPriceForSorting: round2(priceBreakdown.totalPriceForSorting * multiplier),
|
? 0.85
|
||||||
};
|
: 1.0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rate: offer.rate,
|
rate: offer.rate,
|
||||||
priceBreakdown: adjustedBreakdown,
|
calculatedPrice: {
|
||||||
|
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),
|
matchScore: this.calculateMatchScore(offer.rate, input),
|
||||||
serviceLevel: offer.serviceLevel,
|
serviceLevel: offer.serviceLevel,
|
||||||
priceMultiplier: offer.priceMultiplier,
|
originalPrice: {
|
||||||
|
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?.length) {
|
if (input.filters?.serviceLevels && input.filters.serviceLevels.length > 0) {
|
||||||
filteredResults = results.filter(
|
filteredResults = results.filter(
|
||||||
r => r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel)
|
r => r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredResults.sort(
|
// Sort by total price (ascending) - ECONOMIC first, then STANDARD, then RAPID
|
||||||
(a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting
|
filteredResults.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice);
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
results: filteredResults,
|
results: filteredResults,
|
||||||
@ -156,110 +229,197 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
|
|
||||||
async getAvailableCompanies(): Promise<string[]> {
|
async getAvailableCompanies(): Promise<string[]> {
|
||||||
const allRates = await this.loadAllRates();
|
const allRates = await this.loadAllRates();
|
||||||
return [...new Set(allRates.map(r => r.companyName))].sort();
|
const companies = new Set(allRates.map(rate => rate.companyName));
|
||||||
|
return Array.from(companies).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailableContainerTypes(): Promise<string[]> {
|
async getAvailableContainerTypes(): Promise<string[]> {
|
||||||
const allRates = await this.loadAllRates();
|
const allRates = await this.loadAllRates();
|
||||||
return [...new Set(allRates.map(r => r.containerType.getValue()))].sort();
|
const types = new Set(allRates.map(rate => rate.containerType.getValue()));
|
||||||
|
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();
|
||||||
return [...new Set(allRates.map(r => r.originCode.getValue()))].sort();
|
const origins = new Set(allRates.map(rate => rate.origin.getValue()));
|
||||||
|
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 [
|
|
||||||
...new Set(
|
const destinations = new Set(
|
||||||
allRates.filter(r => r.originCode.equals(originCode)).map(r => r.destinationCode.getValue())
|
allRates
|
||||||
),
|
.filter(rate => rate.origin.equals(originCode))
|
||||||
].sort();
|
.map(rate => rate.destination.getValue())
|
||||||
|
);
|
||||||
|
|
||||||
|
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.originCode.getValue();
|
const origin = rate.origin.getValue();
|
||||||
const destination = rate.destinationCode.getValue();
|
const destination = rate.destination.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, [...destinations].sort());
|
result.set(origin, Array.from(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';
|
||||||
return this.csvRateLoader.loadRatesFromCsv(
|
// Pass company name from config to override CSV column value
|
||||||
config.csvFilePath,
|
return this.csvRateLoader.loadRatesFromCsv(config.csvFilePath, email, config.companyName);
|
||||||
email,
|
});
|
||||||
config.companyName
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const failures = results.filter(r => r.status === 'rejected');
|
// Use allSettled to handle missing files gracefully
|
||||||
|
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(`Failed to load ${failures.length} CSV files from database configs`);
|
console.warn(
|
||||||
|
`Failed to load ${failures.length} CSV files:`,
|
||||||
|
failures.map(
|
||||||
|
(f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return rateArrays.flat();
|
||||||
.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 results = await Promise.allSettled(
|
const ratePromises = files.map(file =>
|
||||||
files.map(file => this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com'))
|
this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com')
|
||||||
);
|
);
|
||||||
|
|
||||||
return results
|
// Use allSettled here too for consistency
|
||||||
.filter((r): r is PromiseFulfilledResult<CsvRate[]> => r.status === 'fulfilled')
|
const results = await Promise.allSettled(ratePromises);
|
||||||
.flatMap(r => r.value);
|
const rateArrays = results
|
||||||
|
.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,
|
||||||
input: CsvRateSearchInput
|
volume: Volume
|
||||||
): CsvRate[] {
|
): CsvRate[] {
|
||||||
let filtered = rates;
|
let filtered = rates;
|
||||||
|
|
||||||
if (filters.companies?.length) {
|
// Company filter
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.onlyDirect) {
|
// Volume CBM filter
|
||||||
filtered = filtered.filter(rate => rate.isDirectRoute());
|
if (filters.minVolumeCBM !== undefined) {
|
||||||
|
filtered = filtered.filter(rate => rate.volumeRange.maxCBM >= filters.minVolumeCBM!);
|
||||||
|
}
|
||||||
|
if (filters.maxVolumeCBM !== undefined) {
|
||||||
|
filtered = filtered.filter(rate => rate.volumeRange.minCBM <= filters.maxVolumeCBM!);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.excludeNonDgRoutes) {
|
// Weight KG filter
|
||||||
filtered = filtered.filter(rate => rate.isDgAccepted());
|
if (filters.minWeightKG !== undefined) {
|
||||||
|
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!);
|
||||||
}
|
}
|
||||||
@ -267,55 +427,52 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!);
|
filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.containerTypes?.length) {
|
// Container type filter
|
||||||
|
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())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.departureDate) {
|
// All-in prices only filter
|
||||||
filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!));
|
if (filters.onlyAllInPrices) {
|
||||||
|
filtered = filtered.filter(rate => rate.isAllInPrice());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
|
// Departure date / validity filter
|
||||||
filtered = filtered.filter(rate => {
|
if (filters.departureDate) {
|
||||||
const bd = this.priceCalculator.calculatePrice(rate, {
|
filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!));
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Score (0–100) based on routing type, departure frequency, and rate validity.
|
* Calculate match score (0-100) based on how well rate matches input
|
||||||
* Higher = better match.
|
* Higher score = better match
|
||||||
*/
|
*/
|
||||||
private calculateMatchScore(rate: CsvRate): number {
|
private calculateMatchScore(rate: CsvRate, input: CsvRateSearchInput): number {
|
||||||
let score = 100;
|
let score = 100;
|
||||||
|
|
||||||
// Direct route bonus
|
// Reduce score if volume/weight is near boundaries
|
||||||
if (rate.isDirectRoute()) {
|
const volumeUtilization =
|
||||||
score += 10;
|
(input.volumeCBM - rate.volumeRange.minCBM) /
|
||||||
} else {
|
(rate.volumeRange.maxCBM - rate.volumeRange.minCBM);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frequency bonus (Weekly = best)
|
// Increase score for all-in prices (simpler for customers)
|
||||||
const freqScore = rate.getFrequencyScore(); // 1–4
|
if (rate.isAllInPrice()) {
|
||||||
score += (freqScore - 2) * 5; // Weekly: +10, Bi-Weekly: +5, Bi-Monthly: 0, Monthly: -5
|
score += 5;
|
||||||
|
}
|
||||||
|
|
||||||
// Validity penalty
|
// Reduce score for rates expiring soon
|
||||||
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)
|
||||||
);
|
);
|
||||||
@ -328,7 +485,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,8 +2,16 @@ 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 { DateRange } from '../value-objects/date-range.vo';
|
import { Money } from '../value-objects/money.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;
|
||||||
@ -11,226 +19,415 @@ describe('RateOfferGeneratorService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
service = new RateOfferGeneratorService();
|
service = new RateOfferGeneratorService();
|
||||||
|
|
||||||
// Mock minimal CsvRate compatible with new schema
|
// Créer un tarif de base pour les tests
|
||||||
|
// Prix: 1000 USD / 900 EUR, Transit: 20 jours
|
||||||
mockRate = {
|
mockRate = {
|
||||||
companyName: 'Test Carrier',
|
companyName: 'Test Carrier',
|
||||||
companyEmail: 'test@carrier.com',
|
companyEmail: 'test@carrier.com',
|
||||||
originCFS: 'Fos Sur Mer',
|
origin: PortCode.create('FRPAR'),
|
||||||
originCode: PortCode.create('FRFOS'),
|
destination: PortCode.create('USNYC'),
|
||||||
portOfLoading: 'FOS SUR MER',
|
|
||||||
routing: 'Direct',
|
|
||||||
destinationCFS: 'New York',
|
|
||||||
destinationCode: PortCode.create('USNYC'),
|
|
||||||
destinationCountry: 'USA',
|
|
||||||
containerType: ContainerType.create('LCL'),
|
containerType: ContainerType.create('LCL'),
|
||||||
freight: {
|
volumeRange: { minCBM: 1, maxCBM: 10 },
|
||||||
freightCurrency: 'USD',
|
weightRange: { minKG: 100, maxKG: 5000 },
|
||||||
freightRatePerCBM: 50,
|
palletCount: 0,
|
||||||
freightMinimum: 500,
|
pricing: {
|
||||||
|
pricePerCBM: 100,
|
||||||
|
pricePerKG: 0.5,
|
||||||
|
basePriceUSD: Money.create(1000, 'USD'),
|
||||||
|
basePriceEUR: Money.create(900, 'EUR'),
|
||||||
},
|
},
|
||||||
fob: {
|
currency: 'USD',
|
||||||
fobCurrency: 'EUR',
|
hasSurcharges: false,
|
||||||
fobDocumentation: 55,
|
surchargeBAF: null,
|
||||||
fobISPS: 18,
|
surchargeCAF: null,
|
||||||
fobHandling: 22,
|
surchargeDetails: null,
|
||||||
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: DateRange.create(new Date('2026-01-01'), new Date('2026-12-31'), true),
|
validity: {
|
||||||
|
getStartDate: () => new Date('2024-01-01'),
|
||||||
|
getEndDate: () => new Date('2024-12-31'),
|
||||||
|
},
|
||||||
isValidForDate: () => true,
|
isValidForDate: () => true,
|
||||||
isCurrentlyValid: () => true,
|
|
||||||
matchesRoute: () => true,
|
matchesRoute: () => true,
|
||||||
isDgAccepted: () => true,
|
matchesVolume: () => true,
|
||||||
isDgOnRequest: () => false,
|
matchesPalletCount: () => true,
|
||||||
isDirectRoute: () => true,
|
getPriceInCurrency: () => Money.create(1000, 'USD'),
|
||||||
getFrequencyScore: () => 4,
|
isAllInPrice: () => true,
|
||||||
getRouteDescription: () => 'FRFOS → USNYC',
|
getSurchargeDetails: () => null,
|
||||||
getSummary: () => 'Test Carrier: FRFOS → USNYC',
|
|
||||||
toString: () => 'Test Carrier: FRFOS → USNYC',
|
|
||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateOffers', () => {
|
describe('generateOffers', () => {
|
||||||
it('generates exactly 3 offers (RAPID, STANDARD, ECONOMIC)', () => {
|
it('devrait générer exactement 3 offres (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 has the lowest price multiplier (0.85)', () => {
|
it('ECONOMIC doit être le moins cher', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
|
||||||
expect(economic.priceMultiplier).toBe(0.85);
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
||||||
expect(economic.priceAdjustmentPercent).toBe(-15);
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||||
|
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 has the highest price multiplier (1.2)', () => {
|
it('RAPID doit être le plus cher', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
|
||||||
expect(rapid.priceMultiplier).toBe(1.2);
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
||||||
expect(rapid.priceAdjustmentPercent).toBe(20);
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||||
|
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 has no price adjustment (multiplier = 1.0)', () => {
|
it("STANDARD doit avoir le prix de base (pas d'ajustement)", () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
|
|
||||||
expect(standard.priceMultiplier).toBe(1.0);
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||||
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 has the shortest transit time', () => {
|
it('RAPID doit être le plus rapide (moins de jours de transit)', () => {
|
||||||
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)!;
|
|
||||||
|
|
||||||
expect(rapid.adjustedTransitDays).toBeLessThan(standard.adjustedTransitDays);
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
||||||
expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||||
// 20 * 0.70 = 14
|
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
||||||
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 has the longest transit time', () => {
|
it('ECONOMIC doit être le plus lent (plus de jours de transit)', () => {
|
||||||
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)!;
|
|
||||||
|
|
||||||
expect(economic.adjustedTransitDays).toBeGreaterThan(standard.adjustedTransitDays);
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
|
||||||
// 20 * 1.50 = 30
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||||
expect(economic.adjustedTransitDays).toBe(30);
|
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
|
||||||
|
|
||||||
|
// 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 has no transit adjustment', () => {
|
it("STANDARD doit avoir le transit time de base (pas d'ajustement)", () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const offers = service.generateOffers(mockRate);
|
||||||
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
|
|
||||||
expect(standard.adjustedTransitDays).toBe(20);
|
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
|
||||||
expect(standard.transitAdjustmentPercent).toBe(0);
|
|
||||||
|
// STANDARD doit avoir le transit time de base
|
||||||
|
expect(standard!.adjustedTransitDays).toBe(20);
|
||||||
|
expect(standard!.transitAdjustmentPercent).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers are sorted by priceMultiplier (ECONOMIC → STANDARD → RAPID)', () => {
|
it('les offres doivent être triées par prix croissant (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('clamps transit time to minimum (5 days)', () => {
|
it('doit conserver les informations originales du tarif', () => {
|
||||||
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);
|
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('generates 3 offers per rate', () => {
|
it('doit générer 3 offres par tarif', () => {
|
||||||
const rate2 = { ...mockRate, companyName: 'Another Carrier' } as any;
|
const rate1 = mockRate;
|
||||||
const offers = service.generateOffersForRates([mockRate, rate2]);
|
const rate2 = {
|
||||||
expect(offers).toHaveLength(6);
|
...mockRate,
|
||||||
|
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('generates only RAPID offers', () => {
|
it('doit générer uniquement les offres RAPID', () => {
|
||||||
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('generates only ECONOMIC offers', () => {
|
it('doit générer uniquement les offres ECONOMIC', () => {
|
||||||
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('returns one offer per service level', () => {
|
it('doit retourner la meilleure offre de chaque niveau de service', () => {
|
||||||
const best = service.getBestOffersPerServiceLevel([mockRate]);
|
const rate1 = 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();
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for all levels when no rates', () => {
|
// Toutes doivent provenir du rate2 (moins cher)
|
||||||
const best = service.getBestOffersPerServiceLevel([]);
|
expect(best.rapid!.originalPriceUSD).toBe(800);
|
||||||
expect(best.rapid).toBeNull();
|
expect(best.standard!.originalPriceUSD).toBe(800);
|
||||||
expect(best.standard).toBeNull();
|
expect(best.economic!.originalPriceUSD).toBe(800);
|
||||||
expect(best.economic).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isRateEligible', () => {
|
describe('isRateEligible', () => {
|
||||||
it('accepts a valid rate', () => {
|
it('doit accepter un tarif valide', () => {
|
||||||
expect(service.isRateEligible(mockRate)).toBe(true);
|
expect(service.isRateEligible(mockRate)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a rate with transitDays = 0', () => {
|
it('doit rejeter un tarif avec transit time = 0', () => {
|
||||||
const invalid = { ...mockRate, transitDays: 0 } as any;
|
const invalidRate = { ...mockRate, transitDays: 0 } as any;
|
||||||
expect(service.isRateEligible(invalid)).toBe(false);
|
expect(service.isRateEligible(invalidRate)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a rate with freightRatePerCBM = 0 and freightMinimum = 0', () => {
|
it('doit rejeter un tarif avec prix = 0', () => {
|
||||||
const invalid = {
|
const invalidRate = {
|
||||||
...mockRate,
|
...mockRate,
|
||||||
freight: { ...mockRate.freight, freightRatePerCBM: 0, freightMinimum: 0 },
|
pricing: {
|
||||||
|
...mockRate.pricing,
|
||||||
|
basePriceUSD: Money.create(0, 'USD'),
|
||||||
|
},
|
||||||
} as any;
|
} as any;
|
||||||
expect(service.isRateEligible(invalid)).toBe(false);
|
expect(service.isRateEligible(invalidRate)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an expired rate', () => {
|
it('doit rejeter un tarif expiré', () => {
|
||||||
const expired = { ...mockRate, isValidForDate: () => false } as any;
|
const expiredRate = {
|
||||||
expect(service.isRateEligible(expired)).toBe(false);
|
...mockRate,
|
||||||
|
isValidForDate: () => false,
|
||||||
|
} as any;
|
||||||
|
expect(service.isRateEligible(expiredRate)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Business logic invariants', () => {
|
describe('filterEligibleRates', () => {
|
||||||
it('RAPID priceMultiplier always > ECONOMIC priceMultiplier', () => {
|
it('doit filtrer les tarifs invalides', () => {
|
||||||
const offers = service.generateOffers(mockRate);
|
const validRate = mockRate;
|
||||||
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
const invalidRate1 = { ...mockRate, transitDays: 0 } as any;
|
||||||
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
|
const invalidRate2 = {
|
||||||
expect(rapid.priceMultiplier).toBeGreaterThan(economic.priceMultiplier);
|
...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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('RAPID transit always < ECONOMIC transit for different base days', () => {
|
describe('Validation de la logique métier', () => {
|
||||||
for (const days of [5, 10, 20, 30, 60]) {
|
it('RAPID doit TOUJOURS être plus cher que ECONOMIC', () => {
|
||||||
const rate = { ...mockRate, transitDays: days } as any;
|
// 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 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.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RAPID doit TOUJOURS être plus rapide que ECONOMIC', () => {
|
||||||
|
// Test avec différents transit times de base
|
||||||
|
const transitDays = [5, 10, 20, 30, 60];
|
||||||
|
|
||||||
|
for (const days of transitDays) {
|
||||||
|
const rate = { ...mockRate, transitDays: days } as any;
|
||||||
|
|
||||||
|
const offers = service.generateOffers(rate);
|
||||||
|
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import { CsvRate } from '../entities/csv-rate.entity';
|
|||||||
/**
|
/**
|
||||||
* Service Level Types
|
* Service Level Types
|
||||||
*
|
*
|
||||||
* - RAPID : +20% price, -30% transit (express, priority)
|
* - RAPID: Offre la plus chère + la plus rapide (transit time réduit)
|
||||||
* - STANDARD : base price and transit
|
* - STANDARD: Offre standard (prix et transit time de base)
|
||||||
* - ECONOMIC : -15% price, +50% transit (cheapest, slowest)
|
* - ECONOMIC: Offre la moins chère + la plus lente (transit time augmenté)
|
||||||
*/
|
*/
|
||||||
export enum ServiceLevel {
|
export enum ServiceLevel {
|
||||||
RAPID = 'RAPID',
|
RAPID = 'RAPID',
|
||||||
@ -13,110 +13,243 @@ 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;
|
||||||
priceMultiplier: number;
|
adjustedPriceUSD: 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;
|
priceMultiplier: number; // Multiplicateur de prix (1.0 = pas de changement)
|
||||||
transitMultiplier: number;
|
transitMultiplier: number; // Multiplicateur de transit time (1.0 = pas de changement)
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates RAPID / STANDARD / ECONOMIC variants for a given CSV rate.
|
* Rate Offer Generator Service
|
||||||
*
|
*
|
||||||
* Price adjustment is applied to the total calculated price in the search service —
|
* Service du domaine qui génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV.
|
||||||
* 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,
|
priceMultiplier: 1.2, // +20% du prix de base
|
||||||
transitMultiplier: 0.7,
|
transitMultiplier: 0.7, // -30% du temps de transit (plus rapide)
|
||||||
description: 'Express — Livraison rapide avec service prioritaire',
|
description: 'Express - Livraison rapide avec service prioritaire',
|
||||||
},
|
},
|
||||||
[ServiceLevel.STANDARD]: {
|
[ServiceLevel.STANDARD]: {
|
||||||
priceMultiplier: 1.0,
|
priceMultiplier: 1.0, // Prix de base (pas de changement)
|
||||||
transitMultiplier: 1.0,
|
transitMultiplier: 1.0, // Transit time de base (pas de changement)
|
||||||
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,
|
priceMultiplier: 0.85, // -15% du prix de base
|
||||||
transitMultiplier: 1.5,
|
transitMultiplier: 1.5, // +50% du temps de transit (plus lent)
|
||||||
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;
|
|
||||||
const adjustedTransitDays = this.clampTransit(Math.round(rawTransit));
|
// Calculer les prix ajustés
|
||||||
|
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,
|
||||||
priceMultiplier: config.priceMultiplier,
|
adjustedPriceUSD,
|
||||||
|
adjustedPriceEUR,
|
||||||
adjustedTransitDays,
|
adjustedTransitDays,
|
||||||
originalTransitDays: rate.transitDays,
|
originalPriceUSD: basePriceUSD,
|
||||||
priceAdjustmentPercent: Math.round((config.priceMultiplier - 1) * 100),
|
originalPriceEUR: basePriceEUR,
|
||||||
transitAdjustmentPercent: Math.round((config.transitMultiplier - 1) * 100),
|
originalTransitDays: baseTransitDays,
|
||||||
|
priceAdjustmentPercent,
|
||||||
|
transitAdjustmentPercent,
|
||||||
description: config.description,
|
description: config.description,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ECONOMIC → STANDARD → RAPID (cheapest first)
|
// Trier par prix croissant: ECONOMIC (moins cher) -> STANDARD -> RAPID (plus cher)
|
||||||
return offers.sort((a, b) => a.priceMultiplier - b.priceMultiplier);
|
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[] {
|
||||||
return rates.flatMap(rate => this.generateOffers(rate));
|
const allOffers: RateOffer[] = [];
|
||||||
|
|
||||||
|
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[] {
|
||||||
return rates
|
const offers: RateOffer[] = [];
|
||||||
.map(rate => this.generateOffers(rate).find(o => o.serviceLevel === serviceLevel)!)
|
|
||||||
.filter(Boolean);
|
for (const rate of rates) {
|
||||||
|
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;
|
||||||
// A rate is usable if it has a freight rate or at least a freight minimum
|
if (rate.pricing.basePriceUSD.getAmount() <= 0) return false;
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,58 +5,41 @@ import * as path from 'path';
|
|||||||
/**
|
/**
|
||||||
* CSV Converter Service
|
* CSV Converter Service
|
||||||
*
|
*
|
||||||
* Detects and converts CSV files to the standard 33-column Xpeditis format.
|
* Détecte automatiquement le format du CSV et convertit au format attendu
|
||||||
*
|
* Supporte:
|
||||||
* Standard format columns (33):
|
* - Format standard Xpeditis
|
||||||
* companyName, companyEmail, originCFS, originCode, portOfLoading, routing,
|
* - Format "Frais FOB FRET"
|
||||||
* 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',
|
||||||
'companyEmail',
|
'origin',
|
||||||
'originCFS',
|
'destination',
|
||||||
'originCode',
|
|
||||||
'portOfLoading',
|
|
||||||
'routing',
|
|
||||||
'destinationCFS',
|
|
||||||
'destinationCode',
|
|
||||||
'destinationCountry',
|
|
||||||
'containerType',
|
'containerType',
|
||||||
'freightCurrency',
|
'minVolumeCBM',
|
||||||
'freightRatePerCBM',
|
'maxVolumeCBM',
|
||||||
'freightMinimum',
|
'minWeightKG',
|
||||||
'fobCurrency',
|
'maxWeightKG',
|
||||||
'fobDocumentation',
|
'palletCount',
|
||||||
'fobISPS',
|
'pricePerCBM',
|
||||||
'fobHandling',
|
'pricePerKG',
|
||||||
'fobHandlingUnit',
|
'basePriceUSD',
|
||||||
'fobHandlingMinimum',
|
'basePriceEUR',
|
||||||
'fobSolas',
|
'currency',
|
||||||
'fobCustoms',
|
'hasSurcharges',
|
||||||
'fobAMS_ACI',
|
'surchargeBAF',
|
||||||
'fobISF5',
|
'surchargeCAF',
|
||||||
'fobDGAdmin',
|
'surchargeDetails',
|
||||||
'dgSurchargeCurrency',
|
|
||||||
'dgSurchargeRate',
|
|
||||||
'dgSurchargeUnit',
|
|
||||||
'dgSurchargeMin',
|
|
||||||
'remarks',
|
|
||||||
'frequency',
|
|
||||||
'transitDays',
|
'transitDays',
|
||||||
'validFrom',
|
'validFrom',
|
||||||
'validUntil',
|
'validUntil',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Legacy "Frais FOB FRET" format indicators (older Excel exports)
|
// Headers du format "Frais FOB FRET"
|
||||||
private readonly FOB_FRET_HEADERS = [
|
private readonly FOB_FRET_HEADERS = [
|
||||||
'Origine UN code',
|
'Origine UN code',
|
||||||
'Destination UN code',
|
'Destination UN code',
|
||||||
@ -66,32 +49,259 @@ 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(l => l.trim());
|
const lines = content.split('\n').filter(line => line.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';
|
|
||||||
if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) return 'FOB_FRET';
|
// Vérifier format standard
|
||||||
|
const hasStandardHeaders = this.STANDARD_HEADERS.some(h => headers.includes(h));
|
||||||
|
if (hasStandardHeaders) {
|
||||||
|
return 'STANDARD';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vérifier format FOB FRET
|
||||||
|
const hasFobFretHeaders = this.FOB_FRET_HEADERS.some(h => headers.includes(h));
|
||||||
|
if (hasFobFretHeaders) {
|
||||||
|
return 'FOB_FRET';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return 'UNKNOWN';
|
return 'UNKNOWN';
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
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 { convertedPath: inputPath, wasConverted: false };
|
return {
|
||||||
|
convertedPath: inputPath,
|
||||||
|
wasConverted: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === 'FOB_FRET') {
|
if (format === 'FOB_FRET') {
|
||||||
@ -103,134 +313,6 @@ export class CsvConverterService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(`Unknown CSV format. Please provide a valid CSV file.`);
|
||||||
'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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,109 +4,61 @@ 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 {
|
import { CsvRate } from '@domain/entities/csv-rate.entity';
|
||||||
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standardized 33-column CSV row.
|
* CSV Row Interface
|
||||||
* All suppliers share this exact schema.
|
* Maps to CSV file structure
|
||||||
*/
|
*/
|
||||||
interface CsvRow {
|
interface CsvRow {
|
||||||
// Supplier identity
|
|
||||||
companyName: string;
|
companyName: string;
|
||||||
companyEmail: string;
|
origin: string;
|
||||||
// Route geography
|
destination: string;
|
||||||
originCFS: string;
|
|
||||||
originCode: string;
|
|
||||||
portOfLoading: string;
|
|
||||||
routing: string;
|
|
||||||
destinationCFS: string;
|
|
||||||
destinationCode: string;
|
|
||||||
destinationCountry: string;
|
|
||||||
// Container
|
|
||||||
containerType: string;
|
containerType: string;
|
||||||
// Freight
|
minVolumeCBM: string;
|
||||||
freightCurrency: string;
|
maxVolumeCBM: string;
|
||||||
freightRatePerCBM: string;
|
minWeightKG: string;
|
||||||
freightMinimum: string;
|
maxWeightKG: string;
|
||||||
// FOB charges
|
palletCount: string;
|
||||||
fobCurrency: string;
|
pricePerCBM: string;
|
||||||
fobDocumentation: string;
|
pricePerKG: string;
|
||||||
fobISPS: string;
|
basePriceUSD: string;
|
||||||
fobHandling: string;
|
basePriceEUR: string;
|
||||||
fobHandlingUnit: string;
|
currency: string;
|
||||||
fobHandlingMinimum: string;
|
hasSurcharges: string;
|
||||||
fobSolas: string;
|
surchargeBAF?: string;
|
||||||
fobCustoms: string;
|
surchargeCAF?: string;
|
||||||
fobAMS_ACI: string;
|
surchargeDetails?: 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 = [
|
/**
|
||||||
'companyName',
|
* CSV Rate Loader Adapter
|
||||||
'companyEmail',
|
*
|
||||||
'originCFS',
|
* Infrastructure adapter for loading shipping rates from CSV files.
|
||||||
'originCode',
|
* Implements CsvRateLoaderPort interface.
|
||||||
'portOfLoading',
|
*
|
||||||
'routing',
|
* Features:
|
||||||
'destinationCFS',
|
* - CSV parsing with validation
|
||||||
'destinationCode',
|
* - Mapping CSV rows to domain entities
|
||||||
'destinationCountry',
|
* - Error handling and logging
|
||||||
'containerType',
|
* - File system operations
|
||||||
'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'],
|
||||||
@ -119,6 +71,10 @@ 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',
|
||||||
@ -128,6 +84,10 @@ 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(
|
||||||
@ -135,32 +95,49 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
companyEmail: string,
|
companyEmail: string,
|
||||||
companyNameOverride?: string
|
companyNameOverride?: string
|
||||||
): Promise<CsvRate[]> {
|
): Promise<CsvRate[]> {
|
||||||
this.logger.log(`Loading rates from CSV: ${filePath}`);
|
this.logger.log(
|
||||||
|
`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 {
|
||||||
throw new Error('No MinIO object key');
|
// Fallback to local file
|
||||||
|
throw new Error('No MinIO object key found, using local file');
|
||||||
}
|
}
|
||||||
} catch (minioError: any) {
|
} catch (minioError: any) {
|
||||||
this.logger.warn(`MinIO unavailable: ${minioError.message}. Using local file.`);
|
this.logger.warn(
|
||||||
const fullPath = this.resolvePath(filePath);
|
`⚠️ Failed to load from MinIO: ${minioError.message}. Falling back to local file.`
|
||||||
|
);
|
||||||
|
// 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 {
|
||||||
const fullPath = this.resolvePath(filePath);
|
// Read from 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse CSV
|
||||||
const records: CsvRow[] = parse(fileContent, {
|
const records: CsvRow[] = parse(fileContent, {
|
||||||
columns: true,
|
columns: true,
|
||||||
skip_empty_lines: true,
|
skip_empty_lines: true,
|
||||||
@ -168,48 +145,62 @@ 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 msg = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
throw new Error(`Row ${index + 1} in ${filePath}: ${msg}`);
|
this.logger.error(`Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`);
|
||||||
|
throw new Error(`Invalid data in row ${index + 1} of ${filePath}: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Loaded ${rates.length} rates from ${filePath}`);
|
this.logger.log(`Successfully loaded ${rates.length} rates from ${filePath}`);
|
||||||
return rates;
|
return rates;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
this.logger.error(`Failed to load ${filePath}: ${msg}`);
|
this.logger.error(`Failed to load CSV file ${filePath}: ${errorMessage}`);
|
||||||
throw new Error(`CSV loading failed for ${filePath}: ${msg}`);
|
throw new Error(`CSV loading failed for ${filePath}: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 for company: ${companyName}`);
|
this.logger.warn(`No CSV file configured for company: ${companyName}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const email = `info@${companyName.toLowerCase().replace(/\s+/g, '-')}.com`;
|
|
||||||
return this.loadRatesFromCsv(fileName, email);
|
// Use placeholder email since we don't have access to config repository here
|
||||||
|
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 = this.resolvePath(filePath);
|
const fullPath = path.isAbsolute(filePath)
|
||||||
|
? filePath
|
||||||
|
: path.join(this.csvDirectory, filePath);
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
try {
|
try {
|
||||||
await fs.access(fullPath);
|
await fs.access(fullPath);
|
||||||
} catch {
|
} catch {
|
||||||
return { valid: false, errors: [`File not found: ${filePath}`] };
|
errors.push(`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,
|
||||||
@ -218,154 +209,200 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
return { valid: false, errors: ['CSV file is empty'], rowCount: 0 };
|
errors.push('CSV file is empty');
|
||||||
|
return { valid: false, errors, rowCount: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate structure
|
||||||
try {
|
try {
|
||||||
this.validateCsvStructure(records);
|
this.validateCsvStructure(records);
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
errors.push(e instanceof Error ? e.message : String(e));
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
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 (e) {
|
} catch (error) {
|
||||||
errors.push(`Row ${index + 1}: ${e instanceof Error ? e.message : String(e)}`);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
errors.push(`Row ${index + 1}: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors, rowCount: records.length };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: errors.length === 0,
|
||||||
errors: [`Validation failed: ${e instanceof Error ? e.message : String(e)}`],
|
errors,
|
||||||
|
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 (this.s3Storage && this.csvConfigRepository) {
|
// If MinIO/S3 is configured, list files from there
|
||||||
|
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(c => c.metadata?.minioObjectKey)
|
.filter(config => config.metadata?.minioObjectKey)
|
||||||
.map(c => c.metadata?.minioObjectKey as string);
|
.map(config => config.metadata?.minioObjectKey as string);
|
||||||
if (minioFiles.length > 0) return minioFiles;
|
|
||||||
} catch {
|
if (minioFiles.length > 0) {
|
||||||
// fall through to local
|
this.logger.log(`📂 Found ${minioFiles.length} CSV files in MinIO`);
|
||||||
|
return minioFiles;
|
||||||
|
} else {
|
||||||
|
this.logger.warn('⚠️ No CSV files configured in MinIO, falling back to local files');
|
||||||
}
|
}
|
||||||
}
|
} catch (minioError: any) {
|
||||||
|
this.logger.warn(
|
||||||
try {
|
`⚠️ Failed to list MinIO files: ${minioError.message}. Falling back to local files.`
|
||||||
await fs.access(this.csvDirectory);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await fs.readdir(this.csvDirectory);
|
|
||||||
return files.filter(f => f.endsWith('.csv'));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolvePath(filePath: string): string {
|
|
||||||
return path.isAbsolute(filePath) ? filePath : path.join(this.csvDirectory, filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateCsvStructure(records: CsvRow[]): void {
|
|
||||||
if (records.length === 0) throw new Error('CSV file is empty');
|
|
||||||
const firstRecord = records[0];
|
|
||||||
const missing = REQUIRED_COLUMNS.filter(col => !(col in firstRecord));
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new Error(`Missing required columns: ${missing.join(', ')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapToCsvRate(r: CsvRow, companyEmail: string, companyNameOverride?: string): CsvRate {
|
|
||||||
const companyName = companyNameOverride || r.companyName.trim();
|
|
||||||
// Admin-configured email always takes priority over the value in the CSV row
|
|
||||||
const email = companyEmail?.trim() || r.companyEmail?.trim();
|
|
||||||
|
|
||||||
const freight: FreightPricing = {
|
|
||||||
freightCurrency: r.freightCurrency.toUpperCase(),
|
|
||||||
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 frequency = parseFrequency(r.frequency);
|
|
||||||
|
|
||||||
return new CsvRate(
|
|
||||||
companyName,
|
|
||||||
email,
|
|
||||||
r.originCFS.trim(),
|
|
||||||
PortCode.create(r.originCode.trim()),
|
|
||||||
r.portOfLoading.trim(),
|
|
||||||
r.routing.trim(),
|
|
||||||
r.destinationCFS.trim(),
|
|
||||||
PortCode.create(r.destinationCode.trim()),
|
|
||||||
r.destinationCountry.trim(),
|
|
||||||
ContainerType.create(r.containerType.trim()),
|
|
||||||
freight,
|
|
||||||
fob,
|
|
||||||
dgSurcharge,
|
|
||||||
r.remarks?.trim() || '',
|
|
||||||
frequency,
|
|
||||||
parseInt(r.transitDays, 10),
|
|
||||||
validity
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDgValue(raw: string): DgSurchargeValue {
|
// Fallback: list from local file system
|
||||||
if (!raw || raw.trim() === '') return 0;
|
try {
|
||||||
const upper = raw.trim().toUpperCase();
|
await fs.access(this.csvDirectory);
|
||||||
if (upper === 'ON REQUEST') return 'ON REQUEST';
|
} catch {
|
||||||
if (upper === 'NOT ACCEPTED') return 'NOT ACCEPTED';
|
this.logger.warn(`CSV directory does not exist: ${this.csvDirectory}`);
|
||||||
const num = parseFloat(raw);
|
return [];
|
||||||
return isNaN(num) ? 0 : num;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFrequency(raw: string): FrequencyType {
|
const files = await fs.readdir(this.csvDirectory);
|
||||||
switch (raw?.trim()) {
|
return files.filter(file => file.endsWith('.csv'));
|
||||||
case 'Weekly':
|
} catch (error) {
|
||||||
return 'Weekly';
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
case 'Bi-Weekly':
|
this.logger.error(`Failed to list CSV files: ${errorMessage}`);
|
||||||
return 'Bi-Weekly';
|
return [];
|
||||||
case 'Bi-Monthly':
|
}
|
||||||
return 'Bi-Monthly';
|
}
|
||||||
case 'Monthly':
|
|
||||||
return 'Monthly';
|
/**
|
||||||
default:
|
* Validate that CSV has all required columns
|
||||||
return 'Weekly';
|
*/
|
||||||
|
private validateCsvStructure(records: CsvRow[]): void {
|
||||||
|
const requiredColumns = [
|
||||||
|
'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 missingColumns = requiredColumns.filter(col => !(col in firstRecord));
|
||||||
|
|
||||||
|
if (missingColumns.length > 0) {
|
||||||
|
throw new Error(`Missing required columns: ${missingColumns.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map CSV row to CsvRate domain entity
|
||||||
|
*/
|
||||||
|
private mapToCsvRate(
|
||||||
|
record: CsvRow,
|
||||||
|
companyEmail: string,
|
||||||
|
companyNameOverride?: string
|
||||||
|
): CsvRate {
|
||||||
|
// Parse surcharges
|
||||||
|
const surcharges = this.parseSurcharges(record);
|
||||||
|
|
||||||
|
// Create DateRange
|
||||||
|
const validFrom = new Date(record.validFrom);
|
||||||
|
const validUntil = new Date(record.validUntil);
|
||||||
|
const validity = DateRange.create(validFrom, validUntil, true);
|
||||||
|
|
||||||
|
// Use override company name if provided, otherwise use the one from CSV
|
||||||
|
const companyName = companyNameOverride || record.companyName.trim();
|
||||||
|
|
||||||
|
// Create CsvRate
|
||||||
|
return new CsvRate(
|
||||||
|
companyName,
|
||||||
|
companyEmail,
|
||||||
|
PortCode.create(record.origin),
|
||||||
|
PortCode.create(record.destination),
|
||||||
|
ContainerType.create(record.containerType),
|
||||||
|
{
|
||||||
|
minCBM: parseFloat(record.minVolumeCBM),
|
||||||
|
maxCBM: parseFloat(record.maxVolumeCBM),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minKG: parseFloat(record.minWeightKG),
|
||||||
|
maxKG: parseFloat(record.maxWeightKG),
|
||||||
|
},
|
||||||
|
parseInt(record.palletCount, 10),
|
||||||
|
{
|
||||||
|
pricePerCBM: parseFloat(record.pricePerCBM),
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse surcharges from CSV row
|
||||||
|
*/
|
||||||
|
private parseSurcharges(record: CsvRow): Surcharge[] {
|
||||||
|
const hasSurcharges = record.hasSurcharges.toLowerCase() === 'true';
|
||||||
|
|
||||||
|
if (!hasSurcharges) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const surcharges: Surcharge[] = [];
|
||||||
|
const currency = record.currency.toUpperCase();
|
||||||
|
|
||||||
|
// BAF (Bunker Adjustment Factor)
|
||||||
|
if (record.surchargeBAF && parseFloat(record.surchargeBAF) > 0) {
|
||||||
|
surcharges.push(
|
||||||
|
new Surcharge(
|
||||||
|
SurchargeType.BAF,
|
||||||
|
Money.create(parseFloat(record.surchargeBAF), currency),
|
||||||
|
'Bunker Adjustment Factor'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAF (Currency Adjustment Factor)
|
||||||
|
if (record.surchargeCAF && parseFloat(record.surchargeCAF) > 0) {
|
||||||
|
surcharges.push(
|
||||||
|
new Surcharge(
|
||||||
|
SurchargeType.CAF,
|
||||||
|
Money.create(parseFloat(record.surchargeCAF), currency),
|
||||||
|
'Currency Adjustment Factor'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return surcharges;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,12 +21,9 @@ interface BookingForm {
|
|||||||
volumeCBM: number;
|
volumeCBM: number;
|
||||||
weightKG: number;
|
weightKG: number;
|
||||||
palletCount: number;
|
palletCount: number;
|
||||||
freightTotal: number;
|
priceUSD: number;
|
||||||
freightCurrency: string;
|
priceEUR: number;
|
||||||
fobTotal: number;
|
primaryCurrency: 'USD' | 'EUR';
|
||||||
fobCurrency: string;
|
|
||||||
primaryCurrency: string;
|
|
||||||
totalPriceForSorting: number;
|
|
||||||
transitDays: number;
|
transitDays: number;
|
||||||
containerType: string;
|
containerType: string;
|
||||||
|
|
||||||
@ -64,12 +61,9 @@ function NewBookingPageContent() {
|
|||||||
volumeCBM: 0,
|
volumeCBM: 0,
|
||||||
weightKG: 0,
|
weightKG: 0,
|
||||||
palletCount: 0,
|
palletCount: 0,
|
||||||
freightTotal: 0,
|
priceUSD: 0,
|
||||||
freightCurrency: 'USD',
|
priceEUR: 0,
|
||||||
fobTotal: 0,
|
primaryCurrency: 'EUR',
|
||||||
fobCurrency: 'EUR',
|
|
||||||
primaryCurrency: 'USD',
|
|
||||||
totalPriceForSorting: 0,
|
|
||||||
transitDays: 0,
|
transitDays: 0,
|
||||||
containerType: '',
|
containerType: '',
|
||||||
documents: [],
|
documents: [],
|
||||||
@ -91,12 +85,9 @@ 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'),
|
||||||
freightTotal: rateData.priceBreakdown.totalFreight,
|
priceUSD: rateData.priceUSD,
|
||||||
freightCurrency: rateData.priceBreakdown.freightCurrency,
|
priceEUR: rateData.priceEUR,
|
||||||
fobTotal: rateData.priceBreakdown.totalFob,
|
primaryCurrency: rateData.primaryCurrency as 'USD' | 'EUR',
|
||||||
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,
|
||||||
}));
|
}));
|
||||||
@ -160,14 +151,6 @@ 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);
|
||||||
@ -176,8 +159,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', priceUSD.toString());
|
formDataToSend.append('priceUSD', formData.priceUSD.toString());
|
||||||
formDataToSend.append('priceEUR', priceEUR.toString());
|
formDataToSend.append('priceEUR', formData.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);
|
||||||
@ -363,28 +346,22 @@ 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 mb-4">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">Fret ({formData.freightCurrency})</p>
|
<p className="text-sm text-gray-600">Prix en EUR</p>
|
||||||
<p className="text-2xl font-bold text-gray-800">
|
<p className="text-3xl font-bold text-green-600">
|
||||||
{formatPrice(formData.freightTotal, formData.freightCurrency)}
|
{formatPrice(formData.priceEUR, 'EUR')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{formData.fobTotal > 0 && (
|
{formData.priceUSD > 0 && (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm text-gray-600">FOB ({formData.fobCurrency})</p>
|
<p className="text-sm text-gray-600">Prix en USD</p>
|
||||||
<p className="text-xl font-semibold text-gray-700">
|
<p className="text-xl font-semibold text-gray-700">
|
||||||
{formatPrice(formData.fobTotal, formData.fobCurrency)}
|
{formatPrice(formData.priceUSD, 'USD')}
|
||||||
</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>
|
||||||
|
|
||||||
@ -585,24 +562,10 @@ 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.totalPriceForSorting, formData.primaryCurrency || 'EUR')}
|
{formatPrice(formData.priceEUR, 'EUR')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -40,7 +40,14 @@ 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);
|
||||||
@ -76,8 +83,8 @@ export default function SearchResultsPage() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...results].sort((a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting);
|
const sorted = [...results].sort((a, b) => a.priceEUR - b.priceEUR);
|
||||||
const fastest = [...results].sort((a, b) => (a.adjustedTransitDays ?? a.transitDays) - (b.adjustedTransitDays ?? b.transitDays));
|
const fastest = [...results].sort((a, b) => a.transitDays - b.transitDays);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
eco: sorted[0],
|
eco: sorted[0],
|
||||||
@ -274,7 +281,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.priceBreakdown.totalPriceForSorting)}</p>
|
<p className="text-3xl font-bold text-gray-900">{formatPrice(card.option.priceEUR)}</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">
|
||||||
@ -285,7 +292,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.adjustedTransitDays ?? card.option.transitDays })}
|
{t('transitDays', { days: card.option.transitDays })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
@ -327,32 +334,34 @@ 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.priceBreakdown.totalPriceForSorting)}</p>
|
<p className="text-3xl font-bold text-blue-600">{formatPrice(result.priceEUR)}</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">Fret ({result.priceBreakdown.freightCurrency})</p>
|
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.base')}</p>
|
||||||
<p className="font-semibold text-gray-900">
|
<p className="font-semibold text-gray-900">
|
||||||
{formatPrice(result.priceBreakdown.totalFreight)}
|
{formatPrice(result.priceBreakdown.basePrice)}
|
||||||
</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">FOB ({result.priceBreakdown.fobCurrency})</p>
|
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.volume')}</p>
|
||||||
<p className="font-semibold text-gray-900">
|
<p className="font-semibold text-gray-900">
|
||||||
{formatPrice(result.priceBreakdown.totalFob)}
|
{formatPrice(result.priceBreakdown.volumeCharge)}
|
||||||
</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">Routage</p>
|
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.weight')}</p>
|
||||||
<p className="font-semibold text-gray-900">{result.routing}</p>
|
<p className="font-semibold text-gray-900">
|
||||||
|
{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.adjustedTransitDays ?? result.transitDays })}
|
{t('transitDays', { days: result.transitDays })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -360,14 +369,9 @@ 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.dgSurchargeStatus === 'not_accepted' && (
|
{result.hasSurcharges && (
|
||||||
<span className="text-orange-600 flex items-center">
|
<span className="text-orange-600 flex items-center">
|
||||||
<AlertTriangle className="h-4 w-4 mr-1" /> DG non accepté
|
<AlertTriangle className="h-4 w-4 mr-1" /> {t('surcharges')}
|
||||||
</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>
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
import { renderHook, act } from '@testing-library/react';
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||||
import { useCsvRateSearch } from '@/hooks/useCsvRateSearch';
|
import { useCsvRateSearch } from '@/hooks/useCsvRateSearch';
|
||||||
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
|
import { searchCsvRates } from '@/lib/api/csv-rates';
|
||||||
import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rates';
|
import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rate-filters';
|
||||||
|
|
||||||
jest.mock('@/lib/api/rates', () => ({
|
jest.mock('@/lib/api/csv-rates', () => ({
|
||||||
searchCsvRatesWithOffers: jest.fn(),
|
searchCsvRates: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockSearchCsvRatesWithOffers = jest.mocked(searchCsvRatesWithOffers);
|
const mockSearchCsvRates = jest.mocked(searchCsvRates);
|
||||||
|
|
||||||
const mockRequest: CsvRateSearchRequest = {
|
const mockRequest: CsvRateSearchRequest = {
|
||||||
origin: 'FRLEH',
|
origin: 'Le Havre',
|
||||||
destination: 'CNSHA',
|
destination: 'Shanghai',
|
||||||
volumeCBM: 10,
|
volumeCBM: 10,
|
||||||
weightKG: 5000,
|
weightKG: 5000,
|
||||||
};
|
};
|
||||||
@ -19,58 +19,24 @@ const mockRequest: CsvRateSearchRequest = {
|
|||||||
const mockResponse: CsvRateSearchResponse = {
|
const mockResponse: CsvRateSearchResponse = {
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
companyName: 'SSC Consolidation',
|
companyName: 'Maersk',
|
||||||
companyEmail: 'bookings@ssc.com',
|
origin: 'Le Havre',
|
||||||
originCFS: 'Le Havre',
|
destination: 'Shanghai',
|
||||||
origin: 'FRLEH',
|
containerType: '40ft',
|
||||||
portOfLoading: 'LE HAVRE',
|
priceUSD: 2500,
|
||||||
routing: 'Direct',
|
priceEUR: 2300,
|
||||||
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,
|
||||||
frequency: 'Weekly',
|
surchargeDetails: null,
|
||||||
transitDays: 33,
|
transitDays: 30,
|
||||||
validUntil: '2026-12-31',
|
validUntil: '2024-12-31',
|
||||||
dgAccepted: true,
|
|
||||||
dgSurchargeStatus: 'computed',
|
|
||||||
remarks: '',
|
|
||||||
source: 'CSV',
|
source: 'CSV',
|
||||||
matchScore: 110,
|
matchScore: 95,
|
||||||
serviceLevel: 'STANDARD',
|
|
||||||
priceMultiplier: 1.0,
|
|
||||||
originalTransitDays: 33,
|
|
||||||
adjustedTransitDays: 33,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
totalResults: 1,
|
totalResults: 1,
|
||||||
searchedFiles: ['ssc-consolidation.csv'],
|
searchedFiles: ['maersk-rates.csv'],
|
||||||
searchedAt: '2026-05-11T10:00:00Z',
|
searchedAt: '2024-03-01T10:00:00Z',
|
||||||
appliedFilters: {},
|
appliedFilters: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -108,8 +74,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: any) => void;
|
let resolveSearch: (v: CsvRateSearchResponse) => void;
|
||||||
mockSearchCsvRatesWithOffers.mockReturnValue(
|
mockSearchCsvRates.mockReturnValue(
|
||||||
new Promise(resolve => {
|
new Promise(resolve => {
|
||||||
resolveSearch = resolve;
|
resolveSearch = resolve;
|
||||||
})
|
})
|
||||||
@ -129,7 +95,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 () => {
|
||||||
mockSearchCsvRatesWithOffers.mockResolvedValue(mockResponse as any);
|
mockSearchCsvRates.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const { result } = renderHook(() => useCsvRateSearch());
|
const { result } = renderHook(() => useCsvRateSearch());
|
||||||
|
|
||||||
@ -142,8 +108,8 @@ describe('useCsvRateSearch', () => {
|
|||||||
expect(result.current.error).toBeNull();
|
expect(result.current.error).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls searchCsvRatesWithOffers with the given request', async () => {
|
it('calls searchCsvRates with the given request', async () => {
|
||||||
mockSearchCsvRatesWithOffers.mockResolvedValue(mockResponse as any);
|
mockSearchCsvRates.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const { result } = renderHook(() => useCsvRateSearch());
|
const { result } = renderHook(() => useCsvRateSearch());
|
||||||
|
|
||||||
@ -151,20 +117,22 @@ describe('useCsvRateSearch', () => {
|
|||||||
await result.current.search(mockRequest);
|
await result.current.search(mockRequest);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockSearchCsvRatesWithOffers).toHaveBeenCalledWith(mockRequest);
|
expect(mockSearchCsvRates).toHaveBeenCalledWith(mockRequest);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears a previous error when a new search starts', async () => {
|
it('clears a previous error when a new search starts', async () => {
|
||||||
mockSearchCsvRatesWithOffers.mockRejectedValueOnce(new Error('first error'));
|
mockSearchCsvRates.mockRejectedValueOnce(new Error('first error'));
|
||||||
mockSearchCsvRatesWithOffers.mockResolvedValueOnce(mockResponse as any);
|
mockSearchCsvRates.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
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);
|
||||||
});
|
});
|
||||||
@ -174,7 +142,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 () => {
|
||||||
mockSearchCsvRatesWithOffers.mockRejectedValue(new Error('Network error'));
|
mockSearchCsvRates.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
const { result } = renderHook(() => useCsvRateSearch());
|
const { result } = renderHook(() => useCsvRateSearch());
|
||||||
|
|
||||||
@ -188,7 +156,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 () => {
|
||||||
mockSearchCsvRatesWithOffers.mockRejectedValue({});
|
mockSearchCsvRates.mockRejectedValue({});
|
||||||
|
|
||||||
const { result } = renderHook(() => useCsvRateSearch());
|
const { result } = renderHook(() => useCsvRateSearch());
|
||||||
|
|
||||||
@ -196,13 +164,13 @@ describe('useCsvRateSearch', () => {
|
|||||||
await result.current.search(mockRequest);
|
await result.current.search(mockRequest);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.error).toBe('Erreur lors de la recherche de tarifs');
|
expect(result.current.error).toBe('Failed to search rates');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('reset', () => {
|
describe('reset', () => {
|
||||||
it('clears data, error, and loading', async () => {
|
it('clears data, error, and loading', async () => {
|
||||||
mockSearchCsvRatesWithOffers.mockResolvedValue(mockResponse as any);
|
mockSearchCsvRates.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
const { result } = renderHook(() => useCsvRateSearch());
|
const { result } = renderHook(() => useCsvRateSearch());
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
@ -6,21 +15,22 @@ 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() {
|
||||||
const [origin, setOrigin] = useState('FRFOS');
|
// Search parameters
|
||||||
const [destination, setDestination] = useState('CNSHA');
|
const [origin, setOrigin] = useState('NLRTM');
|
||||||
const [volumeCBM, setVolumeCBM] = useState(10);
|
const [destination, setDestination] = useState('USNYC');
|
||||||
const [weightKG, setWeightKG] = useState(2500);
|
const [volumeCBM, setVolumeCBM] = useState(25.5);
|
||||||
const [hasDangerousGoods, setHasDangerousGoods] = useState(false);
|
const [weightKG, setWeightKG] = useState(3500);
|
||||||
|
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();
|
||||||
|
|
||||||
@ -30,49 +40,54 @@ export default function CsvRateSearchPage() {
|
|||||||
destination,
|
destination,
|
||||||
volumeCBM,
|
volumeCBM,
|
||||||
weightKG,
|
weightKG,
|
||||||
|
palletCount,
|
||||||
containerType: 'LCL',
|
containerType: 'LCL',
|
||||||
hasDangerousGoods,
|
|
||||||
filters,
|
filters,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBooking = (result: CsvRateSearchResult) => {
|
const handleResetFilters = () => {
|
||||||
alert(
|
setFilters({});
|
||||||
`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">Comparateur de tarifs LCL</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Recherche de tarifs CSV</h1>
|
||||||
<p className="text-muted-foreground mt-2">
|
<p className="text-muted-foreground mt-2">
|
||||||
Comparez les tarifs de fret maritime LCL multi-fournisseurs avec détail FOB et fret
|
Recherchez des tarifs de transport maritime avec filtres avancés
|
||||||
</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">
|
||||||
{/* Filtres */}
|
{/* Left Column: Filters */}
|
||||||
<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={() => setFilters({})}
|
onReset={handleResetFilters}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Formulaire + résultats */}
|
{/* Right Column: Search Form + Results */}
|
||||||
<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, volume et poids pour obtenir des tarifs comparés
|
Indiquez votre trajet et les dimensions de votre envoi
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Origine / Destination */}
|
{/* Origin and 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">
|
||||||
@ -82,11 +97,13 @@ 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="FRFOS"
|
placeholder="NLRTM"
|
||||||
maxLength={5}
|
maxLength={5}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">Code UN/LOCODE (ex: FRFOS, FRLEH)</p>
|
<p className="text-xs text-muted-foreground">Code UN/LOCODE (5 caractères)</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>
|
||||||
@ -95,56 +112,49 @@ 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="CNSHA"
|
placeholder="USNYC"
|
||||||
maxLength={5}
|
maxLength={5}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">Code UN/LOCODE (ex: CNSHA, USNYC)</p>
|
<p className="text-xs text-muted-foreground">Code UN/LOCODE (5 caractères)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Volume / Poids */}
|
{/* Volume, Weight, Pallets */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<VolumeWeightInput
|
||||||
|
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 htmlFor="volume">
|
<Label>Devise d'affichage</Label>
|
||||||
Volume (CBM) <span className="text-red-500">*</span>
|
<div className="flex gap-2">
|
||||||
</Label>
|
<Button
|
||||||
<Input
|
type="button"
|
||||||
id="volume"
|
variant={currency === 'USD' ? 'default' : 'outline'}
|
||||||
type="number"
|
onClick={() => setCurrency('USD')}
|
||||||
min={0.1}
|
disabled={loading}
|
||||||
step={0.1}
|
>
|
||||||
value={volumeCBM}
|
USD ($)
|
||||||
onChange={e => setVolumeCBM(parseFloat(e.target.value) || 0)}
|
</Button>
|
||||||
/>
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
<div className="space-y-2">
|
variant={currency === 'EUR' ? 'default' : 'outline'}
|
||||||
<Label htmlFor="weight">
|
onClick={() => setCurrency('EUR')}
|
||||||
Poids (kg) <span className="text-red-500">*</span>
|
disabled={loading}
|
||||||
</Label>
|
>
|
||||||
<Input
|
EUR (€)
|
||||||
id="weight"
|
</Button>
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
step={100}
|
|
||||||
value={weightKG}
|
|
||||||
onChange={e => setWeightKG(parseInt(e.target.value, 10) || 0)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Marchandises dangereuses */}
|
{/* Search Button */}
|
||||||
<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}
|
||||||
@ -159,49 +169,58 @@ export default function CsvRateSearchPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Search className="mr-2 h-4 w-4" />
|
<Search className="mr-2 h-4 w-4" />
|
||||||
Comparer les tarifs
|
Rechercher des 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>
|
||||||
{new Date(data.searchedAt).toLocaleString('fr-FR')} — {data.searchedFiles.length}{' '}
|
Recherche effectuée le {new Date(data.searchedAt).toLocaleString('fr-FR')} •{' '}
|
||||||
fournisseur(s) analysé(s) — {data.totalResults} tarif(s) trouvé(s)
|
{data.searchedFiles.length} fichier(s) CSV analysé(s) • {data.totalResults} tarif(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 comparaison</CardTitle>
|
<CardTitle>Résultats de recherche</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{data.totalResults} tarif{data.totalResults > 1 ? 's' : ''} — prix = Fret +
|
{data.totalResults} tarif{data.totalResults > 1 ? 's' : ''} correspondant à vos
|
||||||
Frais FOB (deux devises possibles)
|
critères
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<RateResultsTable results={data.results} onBooking={handleBooking} />
|
<RateResultsTable
|
||||||
|
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 route.</p>
|
<p className="text-muted-foreground">Aucun tarif trouvé pour cette recherche.</p>
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
Vérifiez les codes UN/LOCODE saisis (ex: FRFOS, CNSHA).
|
Essayez d'ajuster vos critères de recherche ou vos filtres.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
@ -31,98 +39,188 @@ export function RateFiltersPanel({
|
|||||||
}: RateFiltersPanelProps) {
|
}: RateFiltersPanelProps) {
|
||||||
const { companies, containerTypes, loading } = useFilterOptions();
|
const { companies, containerTypes, loading } = useFilterOptions();
|
||||||
|
|
||||||
const update = <K extends keyof RateSearchFilters>(key: K, value: RateSearchFilters[K]) => {
|
const updateFilter = <K extends keyof RateSearchFilters>(key: K, value: RateSearchFilters[K]) => {
|
||||||
onFiltersChange({ ...filters, [key]: value });
|
onFiltersChange({
|
||||||
|
...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={onReset}>
|
<Button variant="ghost" size="sm" onClick={handleReset}>
|
||||||
Réinitialiser
|
Réinitialiser
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Fournisseurs */}
|
{/* Compagnies */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Fournisseurs</Label>
|
<Label>Compagnies maritimes</Label>
|
||||||
<CompanyMultiSelect
|
<CompanyMultiSelect
|
||||||
companies={companies}
|
companies={companies}
|
||||||
selected={filters.companies || []}
|
selected={filters.companies || []}
|
||||||
onChange={selected => update('companies', selected)}
|
onChange={selected => updateFilter('companies', selected)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Routing direct uniquement */}
|
{/* Volume CBM */}
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="only-direct"
|
|
||||||
checked={filters.onlyDirect || false}
|
|
||||||
onCheckedChange={checked => update('onlyDirect', checked)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="only-direct" className="cursor-pointer">
|
|
||||||
Routing direct uniquement
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Exclure les routes sans DG */}
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch
|
|
||||||
id="dg-routes"
|
|
||||||
checked={filters.excludeNonDgRoutes || false}
|
|
||||||
onCheckedChange={checked => update('excludeNonDgRoutes', checked)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="dg-routes" className="cursor-pointer">
|
|
||||||
Acceptation DG requise
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Prix (total estimé) */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Prix estimé total</Label>
|
<Label>Volume (CBM)</Label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
placeholder="Min"
|
||||||
|
value={filters.minVolumeCBM || ''}
|
||||||
|
onChange={e =>
|
||||||
|
updateFilter('minVolumeCBM', parseFloat(e.target.value) || undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
placeholder="Max"
|
||||||
|
value={filters.maxVolumeCBM || ''}
|
||||||
|
onChange={e =>
|
||||||
|
updateFilter('maxVolumeCBM', parseFloat(e.target.value) || undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Poids (kg) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Poids (kg)</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={100}
|
||||||
|
placeholder="Min"
|
||||||
|
value={filters.minWeightKG || ''}
|
||||||
|
onChange={e =>
|
||||||
|
updateFilter('minWeightKG', parseInt(e.target.value, 10) || undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={100}
|
||||||
|
placeholder="Max"
|
||||||
|
value={filters.maxWeightKG || ''}
|
||||||
|
onChange={e =>
|
||||||
|
updateFilter('maxWeightKG', parseInt(e.target.value, 10) || undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Palettes */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Nombre de palettes</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>
|
||||||
<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 => update('minPrice', parseFloat(e.target.value) || undefined)}
|
onChange={e => updateFilter('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 => update('maxPrice', parseFloat(e.target.value) || undefined)}
|
onChange={e => updateFilter('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>Transit (jours)</Label>
|
<Label>Durée de 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 => update('minTransitDays', parseInt(e.target.value, 10) || undefined)}
|
onChange={e =>
|
||||||
|
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 => update('maxTransitDays', parseInt(e.target.value, 10) || undefined)}
|
onChange={e =>
|
||||||
|
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">
|
||||||
@ -130,7 +228,7 @@ export function RateFiltersPanel({
|
|||||||
<Select
|
<Select
|
||||||
value={filters.containerTypes?.[0] || 'all'}
|
value={filters.containerTypes?.[0] || 'all'}
|
||||||
onValueChange={value =>
|
onValueChange={value =>
|
||||||
update('containerTypes', value === 'all' ? undefined : [value])
|
updateFilter('containerTypes', value === 'all' ? undefined : [value])
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@ -147,23 +245,35 @@ export function RateFiltersPanel({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date de validité */}
|
{/* Prix all-in uniquement */}
|
||||||
|
<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 => update('departureDate', e.target.value || undefined)}
|
onChange={e => updateFilter('departureDate', e.target.value || undefined)}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Filtre les tarifs valides à cette date
|
Filtrer par validité des tarifs à 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</span>
|
<span className="text-sm font-medium">Résultats trouvés</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>
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
@ -19,31 +26,19 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { ArrowUpDown, Info, Mail, AlertTriangle } from 'lucide-react';
|
import { ArrowUpDown, Info } from 'lucide-react';
|
||||||
import type { CsvRateSearchResult, ServiceLevel } from '@/types/rates';
|
import type { CsvRateResult } from '@/types/rate-filters';
|
||||||
|
|
||||||
interface RateResultsTableProps {
|
interface RateResultsTableProps {
|
||||||
results: CsvRateSearchResult[];
|
results: CsvRateResult[];
|
||||||
onBooking?: (result: CsvRateSearchResult) => void;
|
currency?: 'USD' | 'EUR';
|
||||||
|
onBooking?: (result: CsvRateResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortField = 'price' | 'transit' | 'company' | 'matchScore';
|
type SortField = 'price' | 'transit' | 'company' | 'matchScore';
|
||||||
type SortOrder = 'asc' | 'desc';
|
type SortOrder = 'asc' | 'desc';
|
||||||
|
|
||||||
const SERVICE_LEVEL_LABELS: Record<ServiceLevel, { label: string; color: string }> = {
|
export function RateResultsTable({ results, currency = 'USD', onBooking }: RateResultsTableProps) {
|
||||||
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');
|
||||||
|
|
||||||
@ -57,33 +52,45 @@ export function RateResultsTable({ results, onBooking }: RateResultsTableProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sortedResults = [...results].sort((a, b) => {
|
const sortedResults = [...results].sort((a, b) => {
|
||||||
let aVal: number | string;
|
let aValue: number | string;
|
||||||
let bVal: number | string;
|
let bValue: number | string;
|
||||||
|
|
||||||
switch (sortField) {
|
switch (sortField) {
|
||||||
case 'price':
|
case 'price':
|
||||||
aVal = a.priceBreakdown.totalPriceForSorting;
|
aValue = currency === 'USD' ? a.priceUSD : a.priceEUR;
|
||||||
bVal = b.priceBreakdown.totalPriceForSorting;
|
bValue = currency === 'USD' ? b.priceUSD : b.priceEUR;
|
||||||
break;
|
break;
|
||||||
case 'transit':
|
case 'transit':
|
||||||
aVal = a.transitDays;
|
aValue = a.transitDays;
|
||||||
bVal = b.transitDays;
|
bValue = b.transitDays;
|
||||||
break;
|
break;
|
||||||
case 'company':
|
case 'company':
|
||||||
aVal = a.companyName;
|
aValue = a.companyName;
|
||||||
bVal = b.companyName;
|
bValue = b.companyName;
|
||||||
break;
|
break;
|
||||||
case 'matchScore':
|
case 'matchScore':
|
||||||
aVal = a.matchScore;
|
aValue = a.matchScore;
|
||||||
bVal = b.matchScore;
|
bValue = b.matchScore;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sortOrder === 'asc' ? (aVal > bVal ? 1 : -1) : aVal < bVal ? 1 : -1;
|
if (sortOrder === 'asc') {
|
||||||
|
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)}
|
||||||
@ -106,25 +113,23 @@ export function RateResultsTable({ results, onBooking }: RateResultsTableProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border overflow-x-auto">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<SortButton field="company" label="Fournisseur" />
|
<SortButton field="company" label="Compagnie" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>Niveau</TableHead>
|
<TableHead>Source</TableHead>
|
||||||
<TableHead>Route</TableHead>
|
<TableHead>Trajet</TableHead>
|
||||||
<TableHead>Routing</TableHead>
|
|
||||||
<TableHead>Fréquence</TableHead>
|
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<SortButton field="price" label="Prix estimé" />
|
<SortButton field="price" label="Prix" />
|
||||||
</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>
|
||||||
@ -132,132 +137,92 @@ export function RateResultsTable({ results, onBooking }: RateResultsTableProps)
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{sortedResults.map((result, index) => {
|
{sortedResults.map((result, index) => (
|
||||||
const bd = result.priceBreakdown;
|
|
||||||
const sl = result.serviceLevel as ServiceLevel | undefined;
|
|
||||||
const slInfo = sl ? SERVICE_LEVEL_LABELS[sl] : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow key={index}>
|
<TableRow key={index}>
|
||||||
{/* Fournisseur */}
|
{/* Compagnie */}
|
||||||
<TableCell>
|
<TableCell className="font-medium">{result.companyName}</TableCell>
|
||||||
<div className="font-medium text-sm">{result.companyName}</div>
|
|
||||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
|
||||||
<Mail className="h-3 w-3" />
|
|
||||||
{result.companyEmail}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* Niveau de service */}
|
{/* Source (CSV/API) */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{slInfo ? (
|
<Badge variant={result.source === 'CSV' ? 'secondary' : 'default'}>
|
||||||
<span className={`text-xs font-medium px-2 py-1 rounded border ${slInfo.color}`}>
|
{result.source}
|
||||||
{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 className="text-xs text-muted-foreground">→ {result.destinationCFS || result.destination}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
({result.origin} / {result.destination})
|
|
||||||
</div>
|
|
||||||
</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>
|
</Badge>
|
||||||
{result.portOfLoading && result.portOfLoading !== result.origin && (
|
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
|
||||||
via {result.portOfLoading}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Fréquence */}
|
{/* Trajet */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span
|
<div className="text-sm">
|
||||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
<div>
|
||||||
FREQUENCY_BADGE[result.frequency] || 'bg-gray-100 text-gray-700'
|
{result.origin} → {result.destination}
|
||||||
}`}
|
</div>
|
||||||
>
|
<div className="text-muted-foreground">{result.containerType}</div>
|
||||||
{result.frequency}
|
</div>
|
||||||
</span>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Prix */}
|
{/* Prix */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<PriceCell result={result} />
|
<div className="font-semibold">{formatPrice(result.priceUSD, result.priceEUR)}</div>
|
||||||
|
{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 font-medium">{result.transitDays} j</div>
|
<div className="text-sm">{result.transitDays} jours</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">
|
||||||
{new Date(result.validUntil).toLocaleDateString('fr-FR')}
|
Jusqu'au {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>
|
||||||
<span
|
<div className="flex items-center gap-1">
|
||||||
|
<div
|
||||||
className={`text-sm font-medium ${
|
className={`text-sm font-medium ${
|
||||||
result.matchScore >= 105
|
result.matchScore >= 90
|
||||||
? 'text-green-600'
|
? 'text-green-600'
|
||||||
: result.matchScore >= 95
|
: result.matchScore >= 75
|
||||||
? 'text-blue-600'
|
? 'text-yellow-600'
|
||||||
: 'text-gray-500'
|
: 'text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{result.matchScore}
|
{result.matchScore}%
|
||||||
</span>
|
</div>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@ -267,177 +232,24 @@ export function RateResultsTable({ results, onBooking }: RateResultsTableProps)
|
|||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<div className="p-4 border-t bg-muted/50 text-sm text-muted-foreground">
|
{/* Summary footer */}
|
||||||
{results.length} tarif{results.length > 1 ? 's' : ''} trouvé{results.length > 1 ? 's' : ''}
|
<div className="p-4 border-t bg-muted/50">
|
||||||
{' '}— Prix estimés : Fret (USD/EUR) + FOB (EUR). Les devises peuvent différer selon le fournisseur.
|
<div className="flex items-center justify-between text-sm">
|
||||||
</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">
|
<span className="text-muted-foreground">
|
||||||
Surcharge DG ({bd.dgSurchargeCurrency})
|
{results.length} tarif{results.length > 1 ? 's' : ''} trouvé
|
||||||
|
{results.length > 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
<span>{bd.dgSurchargeAmount.toFixed(2)}</span>
|
<div className="flex items-center gap-4">
|
||||||
</div>
|
<span className="text-muted-foreground">
|
||||||
)}
|
Prix affichés en <strong>{currency}</strong>
|
||||||
|
|
||||||
{/* 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>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* CSV Rate Search Hook
|
* CSV Rate Search Hook
|
||||||
*
|
*
|
||||||
* React hook for searching CSV-based rates with service level offers
|
* React hook for searching CSV-based rates with filters
|
||||||
* (ECONOMIC / STANDARD / RAPID).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
|
import { searchCsvRates } from '@/lib/api/csv-rates';
|
||||||
import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rates';
|
import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rate-filters';
|
||||||
|
|
||||||
interface UseCsvRateSearchResult {
|
interface UseCsvRateSearchResult {
|
||||||
data: CsvRateSearchResponse | null;
|
data: CsvRateSearchResponse | null;
|
||||||
@ -27,10 +26,10 @@ export function useCsvRateSearch(): UseCsvRateSearchResult {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await searchCsvRatesWithOffers(request);
|
const response = await searchCsvRates(request);
|
||||||
setData(response as unknown as CsvRateSearchResponse);
|
setData(response);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.message || 'Erreur lors de la recherche de tarifs');
|
setError(err?.message || 'Failed to search rates');
|
||||||
setData(null);
|
setData(null);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -43,5 +42,11 @@ export function useCsvRateSearch(): UseCsvRateSearchResult {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { data, loading, error, search, reset };
|
return {
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
search,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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/v1/api-keys
|
* GET /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/v1/api-keys');
|
return get<ApiKeyDto[]>('/api-keys');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new API key
|
* Create a new API key
|
||||||
* POST /api/v1/api-keys
|
* POST /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/v1/api-keys', data);
|
return post<CreateApiKeyResultDto>('/api-keys', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revoke an API key (immediate and irreversible)
|
* Revoke an API key (immediate and irreversible)
|
||||||
* DELETE /api/v1/api-keys/:id
|
* DELETE /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/v1/api-keys/${id}`);
|
return del<void>(`/api-keys/${id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -457,86 +457,73 @@ 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[];
|
||||||
onlyDirect?: boolean;
|
minVolumeCBM?: number;
|
||||||
excludeNonDgRoutes?: boolean;
|
maxVolumeCBM?: number;
|
||||||
|
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[];
|
||||||
departureDate?: string;
|
onlyAllInPrices?: boolean;
|
||||||
serviceLevels?: ('RAPID' | 'STANDARD' | 'ECONOMIC')[];
|
departureDate?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DgSurchargeStatus = 'computed' | 'on_request' | 'not_accepted';
|
export interface SurchargeItem {
|
||||||
|
code: string;
|
||||||
export interface FobBreakdown {
|
description: string;
|
||||||
documentation: number;
|
amount: number;
|
||||||
isps: number;
|
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
|
||||||
handling: number;
|
|
||||||
solas: number;
|
|
||||||
customs: number;
|
|
||||||
ams_aci: number;
|
|
||||||
isf5: number;
|
|
||||||
dgAdmin: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PriceBreakdown {
|
export interface PriceBreakdown {
|
||||||
freightCharge: number;
|
basePrice: number;
|
||||||
freightCurrency: string;
|
volumeCharge: number;
|
||||||
fobFixed: number;
|
weightCharge: number;
|
||||||
fobHandling: number;
|
palletCharge: number;
|
||||||
fobDG: number;
|
surcharges: SurchargeItem[];
|
||||||
fobCurrency: string;
|
totalSurcharges: number;
|
||||||
fobBreakdown: FobBreakdown;
|
totalPrice: number;
|
||||||
dgSurchargeAmount: number | null;
|
currency: string;
|
||||||
dgSurchargeCurrency: string;
|
|
||||||
dgSurchargeStatus: DgSurchargeStatus;
|
|
||||||
totalFreight: number;
|
|
||||||
totalFob: number;
|
|
||||||
totalPriceForSorting: number;
|
|
||||||
primaryCurrency: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CsvRateSearchResult {
|
export interface CsvRateResult {
|
||||||
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;
|
||||||
frequency: string;
|
hasSurcharges: boolean;
|
||||||
|
surchargeDetails: string | null;
|
||||||
transitDays: number;
|
transitDays: number;
|
||||||
validUntil: string;
|
validUntil: string;
|
||||||
dgAccepted: boolean;
|
source: 'CSV' | 'API';
|
||||||
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: CsvRateSearchResult[];
|
results: CsvRateResult[];
|
||||||
totalResults: number;
|
totalResults: number;
|
||||||
searchedFiles: string[];
|
searchedFiles: string[];
|
||||||
searchedAt: string;
|
searchedAt: string;
|
||||||
|
|||||||
@ -1,18 +1,74 @@
|
|||||||
import type { CsvRateSearchResult, CsvRateSearchResponse, RateSearchFilters } from './rates';
|
/**
|
||||||
|
* Rate Search Filters Types
|
||||||
|
*
|
||||||
|
* TypeScript types for advanced rate search filters
|
||||||
|
* Matches backend DTOs
|
||||||
|
*/
|
||||||
|
|
||||||
// Re-export unified types
|
export interface RateSearchFilters {
|
||||||
export type { RateSearchFilters, CsvRateSearchResult as CsvRateResult, CsvRateSearchResponse };
|
// Company filters
|
||||||
|
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;
|
||||||
|
|||||||
@ -1,95 +1,92 @@
|
|||||||
export type ServiceLevel = 'RAPID' | 'STANDARD' | 'ECONOMIC';
|
/**
|
||||||
export type DgSurchargeStatus = 'computed' | 'on_request' | 'not_accepted';
|
* Rate Search Types
|
||||||
|
*
|
||||||
export interface FobBreakdown {
|
* TypeScript types for rate search functionality
|
||||||
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;
|
||||||
filters?: RateSearchFilters;
|
requiresSpecialHandling?: boolean;
|
||||||
|
requiresTailgate?: boolean;
|
||||||
|
requiresStraps?: boolean;
|
||||||
|
requiresThermalCover?: boolean;
|
||||||
|
hasRegulatedProducts?: boolean;
|
||||||
|
requiresAppointment?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RateSearchFilters {
|
/**
|
||||||
companies?: string[];
|
* Surcharge Details
|
||||||
onlyDirect?: boolean;
|
*/
|
||||||
excludeNonDgRoutes?: boolean;
|
export interface Surcharge {
|
||||||
minPrice?: number;
|
code: string;
|
||||||
maxPrice?: number;
|
description: string;
|
||||||
currency?: 'USD' | 'EUR';
|
amount: number;
|
||||||
minTransitDays?: number;
|
type: string;
|
||||||
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;
|
||||||
frequency: string;
|
hasSurcharges: boolean;
|
||||||
|
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;
|
||||||
priceMultiplier?: number;
|
originalPrice?: {
|
||||||
|
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: string;
|
searchedAt: Date;
|
||||||
appliedFilters: Record<string, any>;
|
appliedFilters: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,8 +82,6 @@ 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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user