From 71d131f4cb79e06cfc0a7985cd4635bd7b084bcb Mon Sep 17 00:00:00 2001 From: David Date: Tue, 12 May 2026 01:11:04 +0200 Subject: [PATCH] fix search rates --- .../controllers/rates.controller.ts | 26 +- .../application/dto/csv-rate-search.dto.ts | 402 ++++----------- .../dto/rate-search-filters.dto.ts | 82 +-- .../application/mappers/csv-rate.mapper.ts | 114 ++--- .../src/domain/entities/csv-rate.entity.ts | 271 +++------- .../domain/ports/in/search-csv-rates.port.ts | 153 ++---- .../csv-rate-price-calculator.service.ts | 319 +++++------- .../services/csv-rate-search.service.ts | 355 ++++--------- .../rate-offer-generator.service.spec.ts | 443 +++++----------- .../services/rate-offer-generator.service.ts | 211 ++------ .../csv-loader/csv-converter.service.ts | 432 +++++++--------- .../csv-loader/csv-rate-loader.adapter.ts | 427 ++++++++-------- .../[locale]/dashboard/booking/new/page.tsx | 75 ++- .../search-advanced/results/page.tsx | 42 +- .../__tests__/hooks/useCsvRateSearch.test.tsx | 104 ++-- .../src/app/rates/csv-search/page.tsx | 163 +++--- .../rate-search/RateFiltersPanel.tsx | 240 +++------ .../rate-search/RateResultsTable.tsx | 472 ++++++++++++------ apps/frontend/src/hooks/useCsvRateSearch.ts | 21 +- apps/frontend/src/types/api.ts | 83 +-- apps/frontend/src/types/rate-filters.ts | 64 +-- apps/frontend/src/types/rates.ts | 121 ++--- docker-compose.full.yml | 2 + 23 files changed, 1784 insertions(+), 2838 deletions(-) diff --git a/apps/backend/src/application/controllers/rates.controller.ts b/apps/backend/src/application/controllers/rates.controller.ts index 18c045f..d698c3b 100644 --- a/apps/backend/src/application/controllers/rates.controller.ts +++ b/apps/backend/src/application/controllers/rates.controller.ts @@ -166,27 +166,16 @@ export class RatesController { ); try { - // Map DTO to domain input const searchInput = { origin: dto.origin, destination: dto.destination, volumeCBM: dto.volumeCBM, weightKG: dto.weightKG, - palletCount: dto.palletCount ?? 0, containerType: dto.containerType, - filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), - - // Service requirements for detailed pricing hasDangerousGoods: dto.hasDangerousGoods ?? false, - requiresSpecialHandling: dto.requiresSpecialHandling ?? false, - requiresTailgate: dto.requiresTailgate ?? false, - requiresStraps: dto.requiresStraps ?? false, - requiresThermalCover: dto.requiresThermalCover ?? false, - hasRegulatedProducts: dto.hasRegulatedProducts ?? false, - requiresAppointment: dto.requiresAppointment ?? false, + filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), }; - // Execute CSV rate search const result = await this.csvRateSearchService.execute(searchInput); // Map domain output to response DTO @@ -241,27 +230,16 @@ export class RatesController { ); try { - // Map DTO to domain input const searchInput = { origin: dto.origin, destination: dto.destination, volumeCBM: dto.volumeCBM, weightKG: dto.weightKG, - palletCount: dto.palletCount ?? 0, containerType: dto.containerType, - filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), - - // Service requirements for detailed pricing hasDangerousGoods: dto.hasDangerousGoods ?? false, - requiresSpecialHandling: dto.requiresSpecialHandling ?? false, - requiresTailgate: dto.requiresTailgate ?? false, - requiresStraps: dto.requiresStraps ?? false, - requiresThermalCover: dto.requiresThermalCover ?? false, - hasRegulatedProducts: dto.hasRegulatedProducts ?? false, - requiresAppointment: dto.requiresAppointment ?? false, + filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), }; - // Execute CSV rate search WITH OFFERS GENERATION const result = await this.csvRateSearchService.executeWithOffers(searchInput); // Map domain output to response DTO diff --git a/apps/backend/src/application/dto/csv-rate-search.dto.ts b/apps/backend/src/application/dto/csv-rate-search.dto.ts index 53d5f42..da490f7 100644 --- a/apps/backend/src/application/dto/csv-rate-search.dto.ts +++ b/apps/backend/src/application/dto/csv-rate-search.dto.ts @@ -11,384 +11,192 @@ import { import { Type } from 'class-transformer'; 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 { - @ApiProperty({ - description: 'Origin port code (UN/LOCODE format)', - example: 'NLRTM', - pattern: '^[A-Z]{2}[A-Z0-9]{3}$', - }) + @ApiProperty({ description: 'Origin UN/LOCODE', example: 'FRFOS' }) @IsNotEmpty() @IsString() origin: string; - @ApiProperty({ - description: 'Destination port code (UN/LOCODE format)', - example: 'USNYC', - pattern: '^[A-Z]{2}[A-Z0-9]{3}$', - }) + @ApiProperty({ description: 'Destination UN/LOCODE', example: 'CNSHA' }) @IsNotEmpty() @IsString() destination: string; - @ApiProperty({ - description: 'Volume in cubic meters (CBM)', - minimum: 0.01, - example: 25.5, - }) + @ApiProperty({ description: 'Volume in cubic meters (CBM)', minimum: 0.01, example: 10.5 }) @IsNotEmpty() @IsNumber() @Min(0.01) volumeCBM: number; - @ApiProperty({ - description: 'Weight in kilograms', - minimum: 1, - example: 3500, - }) + @ApiProperty({ description: 'Weight in kilograms', minimum: 1, example: 2500 }) @IsNotEmpty() @IsNumber() @Min(1) weightKG: number; - @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', - }) + @ApiPropertyOptional({ description: 'Container type filter', example: 'LCL' }) @IsOptional() @IsString() containerType?: string; - @ApiPropertyOptional({ - description: 'Advanced filters for narrowing results', - type: RateSearchFiltersDto, - }) - @IsOptional() - @ValidateNested() - @Type(() => RateSearchFiltersDto) - filters?: RateSearchFiltersDto; - - // Service requirements for detailed price calculation - @ApiPropertyOptional({ - description: 'Cargo contains dangerous goods (DG)', - example: true, - default: false, - }) + @ApiPropertyOptional({ description: 'Cargo contains dangerous goods', example: false }) @IsOptional() @IsBoolean() hasDangerousGoods?: boolean; - @ApiPropertyOptional({ - description: 'Requires special handling', - example: true, - default: false, - }) + @ApiPropertyOptional({ description: 'Advanced filters', type: RateSearchFiltersDto }) @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; + @ValidateNested() + @Type(() => RateSearchFiltersDto) + filters?: RateSearchFiltersDto; } -/** - * CSV Rate Search Response DTO - * - * Response containing matching rates with calculated prices - */ export class CsvRateSearchResponseDto { - @ApiProperty({ - description: 'Array of matching rate results', - type: [Object], // Will be replaced with RateResultDto - }) + @ApiProperty({ description: 'Array of matching rate results', type: [Object] }) results: CsvRateResultDto[]; - @ApiProperty({ - description: 'Total number of results found', - example: 15, - }) + @ApiProperty({ description: 'Total number of results', example: 12 }) totalResults: number; - @ApiProperty({ - description: 'CSV files that were searched', - type: [String], - example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'], - }) + @ApiProperty({ description: 'CSV files searched', type: [String] }) searchedFiles: string[]; - @ApiProperty({ - description: 'Timestamp when search was executed', - example: '2025-10-23T10:30:00Z', - }) + @ApiProperty({ description: 'Timestamp of search', example: '2026-05-11T10:30:00Z' }) searchedAt: Date; - @ApiProperty({ - description: 'Filters that were applied to the search', - type: RateSearchFiltersDto, - }) + @ApiProperty({ description: 'Applied filters' }) appliedFilters: RateSearchFiltersDto; } -/** - * Surcharge Item DTO - */ -export class SurchargeItemDto { - @ApiProperty({ - description: 'Surcharge code', - example: 'DG_FEE', - }) - code: string; - - @ApiProperty({ - description: 'Surcharge description', - example: 'Dangerous goods fee', - }) - description: string; - - @ApiProperty({ - description: 'Surcharge amount in currency', - example: 65.0, - }) - amount: number; - - @ApiProperty({ - description: 'Type of surcharge calculation', - enum: ['FIXED', 'PER_UNIT', 'PERCENTAGE'], - example: 'FIXED', - }) - type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE'; +export class FobBreakdownDto { + documentation: number; + isps: number; + handling: number; + solas: number; + customs: number; + ams_aci: number; + isf5: number; + dgAdmin: number; } -/** - * Price Breakdown DTO - */ export class PriceBreakdownDto { - @ApiProperty({ - description: 'Base price before any charges', - example: 0, + @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, }) - basePrice: number; + dgSurchargeAmount: number | null; + + @ApiProperty({ description: 'DG surcharge currency', example: 'EUR' }) + dgSurchargeCurrency: string; @ApiProperty({ - description: 'Charge based on volume (CBM)', - example: 150.0, + description: 'DG surcharge status', + enum: ['computed', 'on_request', 'not_accepted'], + example: 'computed', }) - volumeCharge: number; + dgSurchargeStatus: string; - @ApiProperty({ - description: 'Charge based on weight (KG)', - example: 25.0, - }) - weightCharge: number; + @ApiProperty({ description: 'Total freight in freightCurrency', example: 420.0 }) + totalFreight: number; - @ApiProperty({ - description: 'Charge for pallets', - example: 125.0, - }) - palletCharge: number; + @ApiProperty({ description: 'Total FOB in fobCurrency', example: 245 }) + totalFob: number; - @ApiProperty({ - description: 'List of all surcharges', - type: [SurchargeItemDto], - }) - surcharges: SurchargeItemDto[]; + @ApiProperty({ description: 'Sum for sorting (currency-naive)', example: 665.0 }) + totalPriceForSorting: number; - @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; + @ApiProperty({ description: 'Primary currency', example: 'USD' }) + primaryCurrency: string; } -/** - * Single CSV Rate Result DTO - */ export class CsvRateResultDto { - @ApiProperty({ - description: 'Company name', - example: 'SSC Consolidation', - }) + @ApiProperty({ example: 'SSC Consolidation' }) companyName: string; - @ApiProperty({ - description: 'Company email for booking requests', - example: 'bookings@sscconsolidation.com', - }) + @ApiProperty({ example: 'bookings@ssc.com' }) companyEmail: string; - @ApiProperty({ - description: 'Origin port code', - example: 'NLRTM', - }) + @ApiProperty({ description: 'Origin CFS name', example: 'Fos Sur Mer' }) + originCFS: string; + + @ApiProperty({ description: 'Origin UN/LOCODE', example: 'FRFOS' }) origin: string; - @ApiProperty({ - description: 'Destination port code', - example: 'USNYC', - }) + @ApiProperty({ description: 'Port of loading', example: 'FOS SUR MER' }) + portOfLoading: string; + + @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; - @ApiProperty({ - description: 'Container type', - example: 'LCL', - }) + @ApiProperty({ description: 'Destination country', example: 'China' }) + destinationCountry: string; + + @ApiProperty({ example: 'LCL' }) containerType: string; - @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, - }) + @ApiProperty({ description: 'Detailed price breakdown', type: PriceBreakdownDto }) priceBreakdown: PriceBreakdownDto; - @ApiProperty({ - description: 'Whether this rate has separate surcharges', - example: true, - }) - hasSurcharges: boolean; + @ApiProperty({ description: 'Departure frequency', example: 'Weekly' }) + frequency: string; - @ApiProperty({ - description: 'Details of surcharges if any', - example: 'BAF+CAF included', - nullable: true, - }) - surchargeDetails: string | null; - - @ApiProperty({ - description: 'Transit time in days', - example: 28, - }) + @ApiProperty({ description: 'Transit time (adjusted if service level)', example: 28 }) transitDays: number; - @ApiProperty({ - description: 'Rate validity end date', - example: '2025-12-31', - }) + @ApiProperty({ description: 'Rate validity end date', example: '2026-12-31' }) validUntil: string; - @ApiProperty({ - description: 'Source of the rate', - enum: ['CSV', 'API'], - example: 'CSV', - }) - source: 'CSV' | 'API'; + @ApiProperty({ description: 'Whether DG cargo is accepted', example: true }) + dgAccepted: boolean; - @ApiProperty({ - description: 'Match score (0-100) indicating how well this rate matches the search', - minimum: 0, - maximum: 100, - example: 95, - }) + @ApiProperty({ description: 'DG surcharge status', example: 'computed' }) + dgSurchargeStatus: string; + + @ApiProperty({ description: 'Internal remarks', example: 'GR1/GR2' }) + remarks: string; + + @ApiProperty({ example: 'CSV' }) + source: 'CSV'; + + @ApiProperty({ description: 'Match score 0-100', example: 95 }) matchScore: number; - @ApiPropertyOptional({ - description: 'Service level (only present when using search-csv-offers endpoint)', - enum: ['RAPID', 'STANDARD', 'ECONOMIC'], - example: 'RAPID', - }) + @ApiPropertyOptional({ enum: ['RAPID', 'STANDARD', 'ECONOMIC'] }) serviceLevel?: string; - @ApiPropertyOptional({ - description: 'Original price before service level adjustment', - example: { usd: 1500.0, eur: 1350.0 }, - }) - originalPrice?: { - usd: number; - eur: number; - }; + @ApiPropertyOptional({ description: 'Price multiplier for service level', example: 1.0 }) + priceMultiplier?: number; @ApiPropertyOptional({ description: 'Original transit days before service level adjustment', - example: 20, + example: 28, }) originalTransitDays?: number; } diff --git a/apps/backend/src/application/dto/rate-search-filters.dto.ts b/apps/backend/src/application/dto/rate-search-filters.dto.ts index 3270dbe..a3604c3 100644 --- a/apps/backend/src/application/dto/rate-search-filters.dto.ts +++ b/apps/backend/src/application/dto/rate-search-filters.dto.ts @@ -10,15 +10,9 @@ import { IsString, } from 'class-validator'; -/** - * Rate Search Filters DTO - * - * Advanced filters for narrowing down rate search results - * All filters are optional - */ export class RateSearchFiltersDto { @ApiPropertyOptional({ - description: 'List of company names to include in search', + description: 'List of company names to include', type: [String], example: ['SSC Consolidation', 'ECU Worldwide'], }) @@ -28,59 +22,25 @@ export class RateSearchFiltersDto { companies?: string[]; @ApiPropertyOptional({ - description: 'Minimum volume in CBM (cubic meters)', - minimum: 0, - example: 1, + description: 'Only show "Direct" routing (exclude transhipment)', + example: false, }) @IsOptional() - @IsNumber() - @Min(0) - minVolumeCBM?: number; + @IsBoolean() + onlyDirect?: boolean; @ApiPropertyOptional({ - description: 'Maximum volume in CBM (cubic meters)', - minimum: 0, - example: 100, + description: 'Exclude routes where DG is not accepted', + example: false, }) @IsOptional() - @IsNumber() - @Min(0) - maxVolumeCBM?: number; + @IsBoolean() + excludeNonDgRoutes?: boolean; @ApiPropertyOptional({ - description: 'Minimum weight in kilograms', + description: 'Minimum price (totalPriceForSorting)', 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, + example: 500, }) @IsOptional() @IsNumber() @@ -88,9 +48,9 @@ export class RateSearchFiltersDto { minPrice?: number; @ApiPropertyOptional({ - description: 'Maximum price in selected currency', + description: 'Maximum price (totalPriceForSorting)', minimum: 0, - example: 5000, + example: 3000, }) @IsOptional() @IsNumber() @@ -110,7 +70,7 @@ export class RateSearchFiltersDto { @ApiPropertyOptional({ description: 'Maximum transit time in days', minimum: 0, - example: 40, + example: 45, }) @IsOptional() @IsNumber() @@ -120,7 +80,7 @@ export class RateSearchFiltersDto { @ApiPropertyOptional({ description: 'Container types to filter by', type: [String], - example: ['LCL', '20DRY', '40HC'], + example: ['LCL'], }) @IsOptional() @IsArray() @@ -128,7 +88,7 @@ export class RateSearchFiltersDto { containerTypes?: string[]; @ApiPropertyOptional({ - description: 'Preferred currency for price filtering', + description: 'Preferred currency for price display', enum: ['USD', 'EUR'], example: 'USD', }) @@ -136,17 +96,9 @@ export class RateSearchFiltersDto { @IsEnum(['USD', 'EUR']) currency?: 'USD' | 'EUR'; - @ApiPropertyOptional({ - description: 'Only show all-in prices (without separate surcharges)', - example: false, - }) - @IsOptional() - @IsBoolean() - onlyAllInPrices?: boolean; - @ApiPropertyOptional({ description: 'Departure date to check rate validity (ISO 8601)', - example: '2025-06-15', + example: '2026-06-15', }) @IsOptional() @IsDateString() diff --git a/apps/backend/src/application/mappers/csv-rate.mapper.ts b/apps/backend/src/application/mappers/csv-rate.mapper.ts index 82ca23b..f7bdf26 100644 --- a/apps/backend/src/application/mappers/csv-rate.mapper.ts +++ b/apps/backend/src/application/mappers/csv-rate.mapper.ts @@ -1,5 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { CsvRateResultDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto'; +import { + CsvRateResultDto, + CsvRateSearchResponseDto, + PriceBreakdownDto, + FobBreakdownDto, +} from '../dto/csv-rate-search.dto'; import { CsvRateSearchOutput, CsvRateSearchResult, @@ -9,100 +14,92 @@ import { RateSearchFiltersDto } from '../dto/rate-search-filters.dto'; import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto'; 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() export class CsvRateMapper { - /** - * Map DTO filters to domain filters - */ mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined { - if (!dto) { - return undefined; - } - + if (!dto) return undefined; return { companies: dto.companies, - minVolumeCBM: dto.minVolumeCBM, - maxVolumeCBM: dto.maxVolumeCBM, - minWeightKG: dto.minWeightKG, - maxWeightKG: dto.maxWeightKG, - palletCount: dto.palletCount, + onlyDirect: dto.onlyDirect, + excludeNonDgRoutes: dto.excludeNonDgRoutes, minPrice: dto.minPrice, maxPrice: dto.maxPrice, currency: dto.currency, minTransitDays: dto.minTransitDays, maxTransitDays: dto.maxTransitDays, containerTypes: dto.containerTypes, - onlyAllInPrices: dto.onlyAllInPrices, departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined, }; } - /** - * Map domain search result to DTO - */ mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto { 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 { companyName: rate.companyName, companyEmail: rate.companyEmail, - origin: rate.origin.getValue(), - destination: rate.destination.getValue(), + originCFS: rate.originCFS, + origin: rate.originCode.getValue(), + portOfLoading: rate.portOfLoading, + routing: rate.routing, + destinationCFS: rate.destinationCFS, + destination: rate.destinationCode.getValue(), + destinationCountry: rate.destinationCountry, containerType: rate.containerType.getValue(), - priceUSD: result.calculatedPrice.usd, - priceEUR: result.calculatedPrice.eur, - primaryCurrency: result.calculatedPrice.primaryCurrency, - priceBreakdown: { - basePrice: result.priceBreakdown.basePrice, - volumeCharge: result.priceBreakdown.volumeCharge, - weightCharge: result.priceBreakdown.weightCharge, - palletCharge: result.priceBreakdown.palletCharge, - surcharges: result.priceBreakdown.surcharges.map(s => ({ - code: s.code, - description: s.description, - amount: s.amount, - type: s.type, - })), - totalSurcharges: result.priceBreakdown.totalSurcharges, - totalPrice: result.priceBreakdown.totalPrice, - currency: result.priceBreakdown.currency, - }, - hasSurcharges: rate.hasSurcharges(), - surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null, - // Use adjusted transit days if available (service level offers), otherwise use original + priceBreakdown, + frequency: rate.frequency, transitDays: result.adjustedTransitDays ?? rate.transitDays, validUntil: rate.validity.getEndDate().toISOString().split('T')[0], + dgAccepted: rate.isDgAccepted(), + dgSurchargeStatus: bd.dgSurchargeStatus, + remarks: rate.remarks, source: result.source, matchScore: result.matchScore, - // Include service level fields if present serviceLevel: result.serviceLevel, - originalPrice: result.originalPrice, + priceMultiplier: result.priceMultiplier, originalTransitDays: result.originalTransitDays, }; } - /** - * Map domain search output to response DTO - */ mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto { return { - results: output.results.map(result => this.mapSearchResultToDto(result)), + results: output.results.map(r => this.mapSearchResultToDto(r)), totalResults: output.totalResults, searchedFiles: output.searchedFiles, searchedAt: output.searchedAt, - appliedFilters: output.appliedFilters as any, // Already matches DTO structure + appliedFilters: output.appliedFilters as any, }; } - /** - * Map ORM entity to DTO - */ mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto { return { id: entity.id, @@ -118,10 +115,7 @@ export class CsvRateMapper { }; } - /** - * Map multiple config entities to DTOs - */ mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] { - return entities.map(entity => this.mapConfigEntityToDto(entity)); + return entities.map(e => this.mapConfigEntityToDto(e)); } } diff --git a/apps/backend/src/domain/entities/csv-rate.entity.ts b/apps/backend/src/domain/entities/csv-rate.entity.ts index 7b4bd0c..89de1ef 100644 --- a/apps/backend/src/domain/entities/csv-rate.entity.ts +++ b/apps/backend/src/domain/entities/csv-rate.entity.ts @@ -1,60 +1,69 @@ import { PortCode } from '../value-objects/port-code.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'; -/** - * Volume Range - Valid range for CBM - */ -export interface VolumeRange { - minCBM: number; - maxCBM: number; +export type DgSurchargeValue = number | 'ON REQUEST' | 'NOT ACCEPTED'; +export type HandlingUnit = 'W' | 'UP'; // W = tonne revenue (max CBM/T), UP = per CBM +export type FrequencyType = 'Weekly' | 'Bi-Weekly' | 'Bi-Monthly' | 'Monthly'; + +export interface FreightPricing { + freightCurrency: string; + freightRatePerCBM: number; // 0.0 = included/to negotiate + freightMinimum: number; +} + +export interface FobCharges { + fobCurrency: string; + fobDocumentation: number; + fobISPS: number; + fobHandling: number; + fobHandlingUnit: HandlingUnit; + fobHandlingMinimum: number; + fobSolas: number; + fobCustoms: number; + fobAMS_ACI: number; + fobISF5: number; + fobDGAdmin: number; // Only if DG shipment +} + +export interface DgSurchargeInfo { + dgSurchargeCurrency: string; + dgSurchargeRate: DgSurchargeValue; + dgSurchargeUnit: 'UP' | 'LS' | '%'; // per CBM, lump sum, or percentage + dgSurchargeMin: DgSurchargeValue; } /** - * Weight Range - Valid range for KG - */ -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. + * CsvRate — Shipping rate from a consolidator CSV file. * * Business Rules: - * - Price is calculated as: max(volumeCBM * pricePerCBM, weightKG * pricePerKG) + surcharges - * - Rate must be valid (within validity period) to be used - * - Volume and weight must be within specified ranges + * - Route matching uses originCode + destinationCode (UN/LOCODE) + * - Price = max(freightRatePerCBM×V, freightMinimum) + FOB fixed + handling + * - FOB and freight may be in different currencies + * - DG surcharge applies only when hasDangerousGoods = true */ export class CsvRate { constructor( + // Supplier identity public readonly companyName: string, public readonly companyEmail: string, - public readonly origin: PortCode, - public readonly destination: PortCode, + // Route geography + public readonly originCFS: string, + public readonly originCode: PortCode, + public readonly portOfLoading: string, + public readonly routing: string, + public readonly destinationCFS: string, + public readonly destinationCode: PortCode, + public readonly destinationCountry: string, + // Container public readonly containerType: ContainerType, - public readonly volumeRange: VolumeRange, - public readonly weightRange: WeightRange, - public readonly palletCount: number, - public readonly pricing: RatePricing, - public readonly currency: string, // Primary currency (USD or EUR) - public readonly surcharges: SurchargeCollection, + // Pricing + public readonly freight: FreightPricing, + public readonly fob: FobCharges, + public readonly dgSurcharge: DgSurchargeInfo, + // Metadata + public readonly remarks: string, + public readonly frequency: FrequencyType, public readonly transitDays: number, public readonly validity: DateRange ) { @@ -62,178 +71,56 @@ export class CsvRate { } private validate(): void { - if (!this.companyName || this.companyName.trim().length === 0) { - throw new Error('Company name is required'); - } - - 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'); - } + if (!this.companyName?.trim()) throw new Error('Company name is required'); + if (!this.companyEmail?.trim()) throw new Error('Company email is required'); + if (this.transitDays <= 0) throw new Error('Transit days must be positive'); + if (this.freight.freightMinimum < 0) throw new Error('Freight minimum cannot be negative'); + if (this.fob.fobHandling < 0) throw new Error('FOB handling cannot be negative'); } - /** - * 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 { return this.validity.contains(date); } - /** - * Check if rate is currently valid (today is within validity period) - */ isCurrentlyValid(): boolean { 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 { - return this.origin.equals(origin) && this.destination.equals(destination); + return this.originCode.equals(origin) && this.destinationCode.equals(destination); } - /** - * Check if rate has separate surcharges - */ - hasSurcharges(): boolean { - return !this.surcharges.isEmpty(); + isDgAccepted(): boolean { + return this.dgSurcharge.dgSurchargeRate !== 'NOT ACCEPTED'; } - /** - * Get surcharge details as formatted string - */ - getSurchargeDetails(): string { - return this.surcharges.getDetails(); + isDgOnRequest(): boolean { + return this.dgSurcharge.dgSurchargeRate === 'ON REQUEST'; } - /** - * Check if this is an "all-in" rate (no separate surcharges) - */ - isAllInPrice(): boolean { - return this.surcharges.isEmpty(); + isDirectRoute(): boolean { + return this.routing.trim().toLowerCase() === 'direct'; + } + + getFrequencyScore(): number { + switch (this.frequency) { + case 'Weekly': + return 4; + case 'Bi-Weekly': + return 3; + case 'Bi-Monthly': + return 2; + case 'Monthly': + return 1; + default: + return 2; + } } - /** - * Get route description - */ getRouteDescription(): string { - return `${this.origin.getValue()} → ${this.destination.getValue()}`; + return `${this.originCode.getValue()} → ${this.destinationCode.getValue()}`; } - /** - * Get company and route summary - */ getSummary(): string { return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`; } diff --git a/apps/backend/src/domain/ports/in/search-csv-rates.port.ts b/apps/backend/src/domain/ports/in/search-csv-rates.port.ts index 7f56210..ce16fd1 100644 --- a/apps/backend/src/domain/ports/in/search-csv-rates.port.ts +++ b/apps/backend/src/domain/ports/in/search-csv-rates.port.ts @@ -1,160 +1,73 @@ import { CsvRate } from '../../entities/csv-rate.entity'; import { ServiceLevel } from '../../services/rate-offer-generator.service'; +import { PriceBreakdown } from '../../services/csv-rate-price-calculator.service'; + +export { PriceBreakdown }; /** - * Advanced Rate Search Filters - * - * Filters for narrowing down rate search results + * Filters for narrowing CSV rate search results. + * Volume/weight range filters removed — new schema has no per-rate volume limits. */ export interface RateSearchFilters { - // Company filters - companies?: string[]; // List of company names to include + companies?: string[]; - // Volume/Weight filters - minVolumeCBM?: number; - maxVolumeCBM?: number; - minWeightKG?: number; - maxWeightKG?: number; - palletCount?: number; // Exact pallet count (0 = any) - - // Price filters + // Price filter (applied to totalPriceForSorting) minPrice?: number; maxPrice?: number; - currency?: 'USD' | 'EUR'; // Preferred currency for filtering + currency?: 'USD' | 'EUR'; - // Transit filters + // Transit filter minTransitDays?: number; maxTransitDays?: number; - // Container type filters - containerTypes?: string[]; // e.g., ['LCL', '20DRY', '40HC'] + // Route filter + onlyDirect?: boolean; // Only show "Direct" routing - // Surcharge filters - onlyAllInPrices?: boolean; // Only show rates without separate surcharges + // Container type filter + containerTypes?: string[]; - // Date filters - departureDate?: Date; // Filter by validity for specific date + // Date filter + departureDate?: Date; - // Service level filter - serviceLevels?: ServiceLevel[]; // Filter by service level (RAPID, STANDARD, ECONOMIC) + // Service level filter (for offers endpoint) + serviceLevels?: ServiceLevel[]; + + // DG filter + excludeNonDgRoutes?: boolean; // Only show DG-accepted routes } -/** - * CSV Rate Search Input - * - * Parameters for searching rates in CSV system - */ export interface CsvRateSearchInput { - origin: string; // Port code (UN/LOCODE) - destination: string; // Port code (UN/LOCODE) - volumeCBM: number; // Volume in cubic meters - weightKG: number; // Weight in kilograms - palletCount?: number; // Number of pallets (0 if none) - containerType?: string; // Optional container type filter - filters?: RateSearchFilters; // Advanced filters - - // Service requirements for price calculation + origin: string; // UN/LOCODE + destination: string; // UN/LOCODE + volumeCBM: number; + weightKG: number; + containerType?: string; hasDangerousGoods?: boolean; - requiresSpecialHandling?: boolean; - requiresTailgate?: boolean; - requiresStraps?: boolean; - requiresThermalCover?: boolean; - hasRegulatedProducts?: boolean; - requiresAppointment?: boolean; + filters?: RateSearchFilters; } -/** - * 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 { rate: CsvRate; - calculatedPrice: { - usd: number; - eur: number; - primaryCurrency: string; - }; - priceBreakdown: PriceBreakdown; // Detailed price calculation + priceBreakdown: PriceBreakdown; source: 'CSV'; - matchScore: number; // 0-100, how well it matches filters - serviceLevel?: ServiceLevel; // Service level (RAPID, STANDARD, ECONOMIC) if offers are generated - originalPrice?: { - usd: 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) + matchScore: number; + serviceLevel?: ServiceLevel; + priceMultiplier?: number; + originalTransitDays?: number; + adjustedTransitDays?: number; } -/** - * CSV Rate Search Output - * - * Results from CSV rate search - */ export interface CsvRateSearchOutput { results: CsvRateSearchResult[]; totalResults: number; - searchedFiles: string[]; // CSV files searched + searchedFiles: string[]; searchedAt: Date; 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 { - /** - * Execute CSV rate search with filters - * @param input - Search parameters and filters - * @returns Matching rates with calculated prices - */ execute(input: CsvRateSearchInput): Promise; - - /** - * 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; - - /** - * Get available companies in CSV system - * @returns List of company names that have CSV rates - */ getAvailableCompanies(): Promise; - - /** - * Get available container types in CSV system - * @returns List of container types available - */ getAvailableContainerTypes(): Promise; } diff --git a/apps/backend/src/domain/services/csv-rate-price-calculator.service.ts b/apps/backend/src/domain/services/csv-rate-price-calculator.service.ts index 3c505c2..8f36b4f 100644 --- a/apps/backend/src/domain/services/csv-rate-price-calculator.service.ts +++ b/apps/backend/src/domain/services/csv-rate-price-calculator.service.ts @@ -3,217 +3,152 @@ import { CsvRate } from '../entities/csv-rate.entity'; export interface PriceCalculationParams { volumeCBM: number; weightKG: number; - palletCount: number; - hasDangerousGoods: boolean; - requiresSpecialHandling: boolean; - requiresTailgate: boolean; - requiresStraps: boolean; - requiresThermalCover: boolean; - hasRegulatedProducts: boolean; - requiresAppointment: boolean; + hasDangerousGoods?: 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 { - basePrice: number; - volumeCharge: number; - weightCharge: number; - palletCharge: number; - surcharges: SurchargeItem[]; - totalSurcharges: number; - totalPrice: number; - currency: string; -} + // Freight (in freightCurrency) + freightCharge: number; + freightCurrency: string; -export interface SurchargeItem { - code: string; - description: string; - amount: number; - type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE'; + // FOB charges (in fobCurrency) + fobFixed: number; // doc + ISPS + solas + customs + AMS_ACI + ISF5 + fobHandling: number; + fobDG: number; // fobDGAdmin only if DG + fobCurrency: string; + fobBreakdown: FobBreakdown; + + // DG surcharge (fobCurrency or dgSurchargeCurrency) + dgSurchargeAmount: number | null; // null when on_request or not_accepted + dgSurchargeCurrency: string; + dgSurchargeStatus: DgSurchargeStatus; + + // Totals (each in their own currency) + totalFreight: number; // = freightCharge in freightCurrency + totalFob: number; // = fobFixed + fobHandling + fobDG + dgSurcharge in fobCurrency + + // Used for sorting/comparison only — naive sum treating both currencies as equal + // Callers should be aware of potential currency mismatch + totalPriceForSorting: number; + primaryCurrency: string; } /** - * Service de calcul de prix pour les tarifs CSV - * Calcule le prix total basé sur le volume, poids, palettes et services additionnels + * Calculates price for a CSV rate given volume and weight. + * + * 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 { - /** - * Calcule le prix total pour un tarif CSV donné - */ calculatePrice(rate: CsvRate, params: PriceCalculationParams): PriceBreakdown { - // 1. Prix de base - const basePrice = rate.pricing.basePriceUSD.getAmount(); + const V = params.volumeCBM; + const W = params.weightKG / 1000; // convert KG → tonnes for W unit + const isDG = params.hasDangerousGoods ?? false; - // 2. Frais au volume (USD par CBM) - const volumeCharge = rate.pricing.pricePerCBM * params.volumeCBM; + // 1. Freight charge + const freightCharge = + rate.freight.freightRatePerCBM > 0 + ? Math.max(rate.freight.freightRatePerCBM * V, rate.freight.freightMinimum) + : rate.freight.freightMinimum; - // 3. Frais au poids (USD par KG) - const weightCharge = rate.pricing.pricePerKG * params.weightKG; + // 2. Handling — "W" = tonne revenue (max of CBM and tonnes), "UP" = per CBM + const handlingBase = rate.fob.fobHandlingUnit === 'W' ? Math.max(V, W) : V; + const fobHandling = Math.max(rate.fob.fobHandling * handlingBase, rate.fob.fobHandlingMinimum); - // 4. Frais de palettes (25 USD par palette) - const palletCharge = params.palletCount * 25; + // 3. FOB fixed charges + const fobFixed = + rate.fob.fobDocumentation + + rate.fob.fobISPS + + rate.fob.fobSolas + + rate.fob.fobCustoms + + rate.fob.fobAMS_ACI + + rate.fob.fobISF5; - // 5. Surcharges standard du CSV - const standardSurcharges = this.parseStandardSurcharges(rate.getSurchargeDetails(), params); + // 4. DG admin (FOB currency, only if DG) + const fobDG = isDG ? rate.fob.fobDGAdmin : 0; - // 6. Surcharges additionnelles basées sur les services - const additionalSurcharges = this.calculateAdditionalSurcharges(params); + // 5. DG surcharge (own currency, only if DG) + let dgSurchargeAmount: number | null = null; + let dgSurchargeStatus: DgSurchargeStatus = 'computed'; - // 7. Total des surcharges - const allSurcharges = [...standardSurcharges, ...additionalSurcharges]; - const totalSurcharges = allSurcharges.reduce((sum, s) => sum + s.amount, 0); + if (isDG) { + const dgRate = rate.dgSurcharge.dgSurchargeRate; + if (dgRate === 'NOT ACCEPTED') { + dgSurchargeStatus = 'not_accepted'; + } else if (dgRate === 'ON REQUEST') { + dgSurchargeStatus = 'on_request'; + } else { + dgSurchargeStatus = 'computed'; + const dgNum = typeof dgRate === 'number' ? dgRate : parseFloat(String(dgRate)); + let rawDG = 0; + switch (rate.dgSurcharge.dgSurchargeUnit) { + case 'UP': + rawDG = dgNum * V; + break; + case 'LS': + rawDG = dgNum; + break; + case '%': + rawDG = freightCharge * (dgNum / 100); + break; + } + const dgMin = + typeof rate.dgSurcharge.dgSurchargeMin === 'number' ? rate.dgSurcharge.dgSurchargeMin : 0; + dgSurchargeAmount = Math.max(rawDG, dgMin); + } + } - // 8. Prix total - const totalPrice = basePrice + volumeCharge + weightCharge + palletCharge + totalSurcharges; + // 6. Total FOB (in fobCurrency) + const totalFob = fobFixed + fobHandling + fobDG + (dgSurchargeAmount ?? 0); + + // 7. Naive sum for sorting (ignores currency differences) + const totalPriceForSorting = freightCharge + totalFob; return { - basePrice, - volumeCharge, - weightCharge, - palletCharge, - surcharges: allSurcharges, - totalSurcharges, - totalPrice: Math.round(totalPrice * 100) / 100, // Arrondi à 2 décimales - currency: rate.currency || 'USD', + freightCharge: round2(freightCharge), + freightCurrency: rate.freight.freightCurrency, + fobFixed: round2(fobFixed), + fobHandling: round2(fobHandling), + fobDG: round2(fobDG), + fobCurrency: rate.fob.fobCurrency, + fobBreakdown: { + documentation: rate.fob.fobDocumentation, + isps: rate.fob.fobISPS, + handling: round2(fobHandling), + solas: rate.fob.fobSolas, + customs: rate.fob.fobCustoms, + ams_aci: rate.fob.fobAMS_ACI, + isf5: rate.fob.fobISF5, + dgAdmin: isDG ? rate.fob.fobDGAdmin : 0, + }, + dgSurchargeAmount: dgSurchargeAmount !== null ? round2(dgSurchargeAmount) : null, + dgSurchargeCurrency: rate.dgSurcharge.dgSurchargeCurrency, + dgSurchargeStatus, + totalFreight: round2(freightCharge), + totalFob: round2(totalFob), + totalPriceForSorting: round2(totalPriceForSorting), + primaryCurrency: rate.freight.freightCurrency, }; } - - /** - * Parse les surcharges standard du format CSV - * Format: "DOC:10 | ISPS:7 | HANDLING:20 W | DG_FEE:65" - */ - private parseStandardSurcharges( - surchargeDetails: string | null, - params: PriceCalculationParams - ): SurchargeItem[] { - if (!surchargeDetails) { - return []; - } - - const surcharges: SurchargeItem[] = []; - 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 = { - 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, ' '); - } +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; } diff --git a/apps/backend/src/domain/services/csv-rate-search.service.ts b/apps/backend/src/domain/services/csv-rate-search.service.ts index 478c7b3..338e77f 100644 --- a/apps/backend/src/domain/services/csv-rate-search.service.ts +++ b/apps/backend/src/domain/services/csv-rate-search.service.ts @@ -1,7 +1,6 @@ import { CsvRate } from '../entities/csv-rate.entity'; import { PortCode } from '../value-objects/port-code.vo'; import { ContainerType } from '../value-objects/container-type.vo'; -import { Volume } from '../value-objects/volume.vo'; import { SearchCsvRatesPort, CsvRateSearchInput, @@ -11,11 +10,8 @@ import { } from '@domain/ports/in/search-csv-rates.port'; import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port'; import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service'; -import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.service'; +import { RateOfferGeneratorService } from './rate-offer-generator.service'; -/** - * Config Metadata Interface (to avoid circular dependency) - */ interface CsvRateConfig { companyName: string; csvFilePath: string; @@ -25,21 +21,10 @@ interface CsvRateConfig { }; } -/** - * Config Repository Port (simplified interface) - */ export interface CsvRateConfigRepositoryPort { findActiveConfigs(): Promise; } -/** - * 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 { private readonly priceCalculator: CsvRatePriceCalculatorService; private readonly offerGenerator: RateOfferGeneratorService; @@ -54,63 +39,39 @@ export class CsvRateSearchService implements SearchCsvRatesPort { async execute(input: CsvRateSearchInput): Promise { const searchStartTime = new Date(); - - // Parse and validate input const origin = PortCode.create(input.origin); 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(); - - // 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) { const containerType = ContainerType.create(input.containerType); matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType)); } - // Apply advanced filters if (input.filters) { - matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume); + matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, input); } - // Calculate prices and create results const results: CsvRateSearchResult[] = matchingRates.map(rate => { - // Calculate detailed price breakdown const priceBreakdown = this.priceCalculator.calculatePrice(rate, { volumeCBM: input.volumeCBM, weightKG: input.weightKG, - palletCount: input.palletCount ?? 0, hasDangerousGoods: input.hasDangerousGoods ?? false, - requiresSpecialHandling: input.requiresSpecialHandling ?? false, - requiresTailgate: input.requiresTailgate ?? false, - requiresStraps: input.requiresStraps ?? false, - requiresThermalCover: input.requiresThermalCover ?? false, - hasRegulatedProducts: input.hasRegulatedProducts ?? false, - requiresAppointment: input.requiresAppointment ?? false, }); return { rate, - calculatedPrice: { - usd: priceBreakdown.totalPrice, - eur: priceBreakdown.totalPrice, // TODO: Add currency conversion - primaryCurrency: priceBreakdown.currency, - }, priceBreakdown, source: 'CSV' as const, - matchScore: this.calculateMatchScore(rate, input), + matchScore: this.calculateMatchScore(rate), }; }); - // Sort by total price (ascending) - results.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice); + results.sort( + (a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting + ); return { results, @@ -122,101 +83,67 @@ export class CsvRateSearchService implements SearchCsvRatesPort { } /** - * Execute CSV rate search with service level offers generation - * Generates 3 offers (RAPID, STANDARD, ECONOMIC) for each matching rate + * Search with service level offers — returns 3 variants per rate (ECONOMIC / STANDARD / RAPID). + * Price multipliers (0.85 / 1.0 / 1.2) are applied to totalPriceForSorting. */ async executeWithOffers(input: CsvRateSearchInput): Promise { const searchStartTime = new Date(); - - // Parse and validate input const origin = PortCode.create(input.origin); 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(); - - // 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) { const containerType = ContainerType.create(input.containerType); matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType)); } - // Apply advanced filters (before generating offers) if (input.filters) { - matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume); + matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, input); } - // Filter eligible rates for offer generation const eligibleRates = this.offerGenerator.filterEligibleRates(matchingRates); - - // Generate 3 offers (RAPID, STANDARD, ECONOMIC) for each eligible rate const allOffers = this.offerGenerator.generateOffersForRates(eligibleRates); - // Convert offers to search results const results: CsvRateSearchResult[] = allOffers.map(offer => { - // Calculate detailed price breakdown with adjusted prices const priceBreakdown = this.priceCalculator.calculatePrice(offer.rate, { volumeCBM: input.volumeCBM, weightKG: input.weightKG, - palletCount: input.palletCount ?? 0, hasDangerousGoods: input.hasDangerousGoods ?? false, - requiresSpecialHandling: input.requiresSpecialHandling ?? false, - requiresTailgate: input.requiresTailgate ?? false, - requiresStraps: input.requiresStraps ?? false, - requiresThermalCover: input.requiresThermalCover ?? false, - hasRegulatedProducts: input.hasRegulatedProducts ?? false, - requiresAppointment: input.requiresAppointment ?? false, }); - // Apply service level price adjustment to the total price - const adjustedTotalPrice = - priceBreakdown.totalPrice * - (offer.serviceLevel === ServiceLevel.RAPID - ? 1.2 - : offer.serviceLevel === ServiceLevel.ECONOMIC - ? 0.85 - : 1.0); + const multiplier = offer.priceMultiplier; + const adjustedBreakdown = { + ...priceBreakdown, + freightCharge: round2(priceBreakdown.freightCharge * multiplier), + totalFreight: round2(priceBreakdown.totalFreight * multiplier), + totalFob: round2(priceBreakdown.totalFob * multiplier), + totalPriceForSorting: round2(priceBreakdown.totalPriceForSorting * multiplier), + }; return { rate: offer.rate, - calculatedPrice: { - usd: adjustedTotalPrice, - eur: adjustedTotalPrice, // TODO: Add currency conversion - primaryCurrency: priceBreakdown.currency, - }, - priceBreakdown: { - ...priceBreakdown, - totalPrice: adjustedTotalPrice, - }, + priceBreakdown: adjustedBreakdown, source: 'CSV' as const, - matchScore: this.calculateMatchScore(offer.rate, input), + matchScore: this.calculateMatchScore(offer.rate), serviceLevel: offer.serviceLevel, - originalPrice: { - usd: offer.originalPriceUSD, - eur: offer.originalPriceEUR, - }, + priceMultiplier: offer.priceMultiplier, originalTransitDays: offer.originalTransitDays, adjustedTransitDays: offer.adjustedTransitDays, }; }); - // Apply service level filter if specified let filteredResults = results; - if (input.filters?.serviceLevels && input.filters.serviceLevels.length > 0) { + if (input.filters?.serviceLevels?.length) { filteredResults = results.filter( r => r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel) ); } - // Sort by total price (ascending) - ECONOMIC first, then STANDARD, then RAPID - filteredResults.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice); + filteredResults.sort( + (a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting + ); return { results: filteredResults, @@ -229,197 +156,110 @@ export class CsvRateSearchService implements SearchCsvRatesPort { async getAvailableCompanies(): Promise { const allRates = await this.loadAllRates(); - const companies = new Set(allRates.map(rate => rate.companyName)); - return Array.from(companies).sort(); + return [...new Set(allRates.map(r => r.companyName))].sort(); } async getAvailableContainerTypes(): Promise { const allRates = await this.loadAllRates(); - const types = new Set(allRates.map(rate => rate.containerType.getValue())); - return Array.from(types).sort(); + return [...new Set(allRates.map(r => r.containerType.getValue()))].sort(); } - /** - * Get all unique origin port codes from CSV rates - * Used to limit port selection to only those with available routes - */ async getAvailableOrigins(): Promise { const allRates = await this.loadAllRates(); - const origins = new Set(allRates.map(rate => rate.origin.getValue())); - return Array.from(origins).sort(); + return [...new Set(allRates.map(r => r.originCode.getValue()))].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 { const allRates = await this.loadAllRates(); const originCode = PortCode.create(origin); - - const destinations = new Set( - allRates - .filter(rate => rate.origin.equals(originCode)) - .map(rate => rate.destination.getValue()) - ); - - return Array.from(destinations).sort(); + return [ + ...new Set( + allRates.filter(r => r.originCode.equals(originCode)).map(r => r.destinationCode.getValue()) + ), + ].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> { const allRates = await this.loadAllRates(); const routeMap = new Map>(); allRates.forEach(rate => { - const origin = rate.origin.getValue(); - const destination = rate.destination.getValue(); - - if (!routeMap.has(origin)) { - routeMap.set(origin, new Set()); - } + const origin = rate.originCode.getValue(); + const destination = rate.destinationCode.getValue(); + if (!routeMap.has(origin)) routeMap.set(origin, new Set()); routeMap.get(origin)!.add(destination); }); - // Convert Sets to sorted arrays const result = new Map(); routeMap.forEach((destinations, origin) => { - result.set(origin, Array.from(destinations).sort()); + result.set(origin, [...destinations].sort()); }); - return result; } - /** - * Load all rates from all CSV files - */ private async loadAllRates(): Promise { - // If config repository is available, load rates with emails and company names from configs if (this.configRepository) { const configs = await this.configRepository.findActiveConfigs(); - const ratePromises = configs.map(config => { - const email = config.metadata?.companyEmail || 'bookings@example.com'; - // Pass company name from config to override CSV column value - return this.csvRateLoader.loadRatesFromCsv(config.csvFilePath, email, config.companyName); - }); - // Use allSettled to handle missing files gracefully - const results = await Promise.allSettled(ratePromises); - const rateArrays = results - .filter( - (result): result is PromiseFulfilledResult => result.status === 'fulfilled' - ) - .map(result => result.value); - - // Log any failed file loads - const failures = results.filter(result => result.status === 'rejected'); - if (failures.length > 0) { - console.warn( - `Failed to load ${failures.length} CSV files:`, - failures.map( - (f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}` - ) + if (configs.length > 0) { + const results = await Promise.allSettled( + configs.map(config => { + const email = config.metadata?.companyEmail || 'bookings@example.com'; + return this.csvRateLoader.loadRatesFromCsv( + config.csvFilePath, + email, + config.companyName + ); + }) ); + + const failures = results.filter(r => r.status === 'rejected'); + if (failures.length > 0) { + console.warn(`Failed to load ${failures.length} CSV files from database configs`); + } + + return results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .flatMap(r => r.value); } - return rateArrays.flat(); + // 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 ratePromises = files.map(file => - this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com') + const results = await Promise.allSettled( + files.map(file => this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com')) ); - // Use allSettled here too for consistency - const results = await Promise.allSettled(ratePromises); - const rateArrays = results - .filter( - (result): result is PromiseFulfilledResult => result.status === 'fulfilled' - ) - .map(result => result.value); - - return rateArrays.flat(); + return results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .flatMap(r => r.value); } - /** - * Filter rates by route (origin/destination) - */ private filterByRoute(rates: CsvRate[], origin: PortCode, destination: PortCode): CsvRate[] { 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( rates: CsvRate[], filters: RateSearchFilters, - volume: Volume + input: CsvRateSearchInput ): CsvRate[] { let filtered = rates; - // Company filter - if (filters.companies && filters.companies.length > 0) { + if (filters.companies?.length) { filtered = filtered.filter(rate => filters.companies!.includes(rate.companyName)); } - // Volume CBM filter - 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.onlyDirect) { + filtered = filtered.filter(rate => rate.isDirectRoute()); } - // Weight KG filter - 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!); + if (filters.excludeNonDgRoutes) { + filtered = filtered.filter(rate => rate.isDgAccepted()); } - // 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) { filtered = filtered.filter(rate => rate.transitDays >= filters.minTransitDays!); } @@ -427,52 +267,55 @@ export class CsvRateSearchService implements SearchCsvRatesPort { filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!); } - // Container type filter - if (filters.containerTypes && filters.containerTypes.length > 0) { + if (filters.containerTypes?.length) { filtered = filtered.filter(rate => filters.containerTypes!.includes(rate.containerType.getValue()) ); } - // All-in prices only filter - if (filters.onlyAllInPrices) { - filtered = filtered.filter(rate => rate.isAllInPrice()); - } - - // Departure date / validity filter if (filters.departureDate) { filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!)); } + if (filters.minPrice !== undefined || filters.maxPrice !== undefined) { + filtered = filtered.filter(rate => { + const bd = this.priceCalculator.calculatePrice(rate, { + volumeCBM: input.volumeCBM, + weightKG: input.weightKG, + hasDangerousGoods: input.hasDangerousGoods ?? false, + }); + if (filters.minPrice !== undefined && bd.totalPriceForSorting < filters.minPrice) { + return false; + } + if (filters.maxPrice !== undefined && bd.totalPriceForSorting > filters.maxPrice) { + return false; + } + return true; + }); + } + return filtered; } /** - * Calculate match score (0-100) based on how well rate matches input - * Higher score = better match + * Score (0–100) based on routing type, departure frequency, and rate validity. + * Higher = better match. */ - private calculateMatchScore(rate: CsvRate, input: CsvRateSearchInput): number { + private calculateMatchScore(rate: CsvRate): number { let score = 100; - // Reduce score if volume/weight is near boundaries - const volumeUtilization = - (input.volumeCBM - rate.volumeRange.minCBM) / - (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) { + // Direct route bonus + if (rate.isDirectRoute()) { + score += 10; + } else { score -= 5; } - // Increase score for all-in prices (simpler for customers) - if (rate.isAllInPrice()) { - score += 5; - } + // Frequency bonus (Weekly = best) + const freqScore = rate.getFrequencyScore(); // 1–4 + score += (freqScore - 2) * 5; // Weekly: +10, Bi-Weekly: +5, Bi-Monthly: 0, Monthly: -5 - // Reduce score for rates expiring soon + // Validity penalty const daysUntilExpiry = Math.floor( (rate.validity.getEndDate().getTime() - Date.now()) / (1000 * 60 * 60 * 24) ); @@ -485,3 +328,7 @@ export class CsvRateSearchService implements SearchCsvRatesPort { return Math.max(0, Math.min(100, score)); } } + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} diff --git a/apps/backend/src/domain/services/rate-offer-generator.service.spec.ts b/apps/backend/src/domain/services/rate-offer-generator.service.spec.ts index bb0c499..8da197a 100644 --- a/apps/backend/src/domain/services/rate-offer-generator.service.spec.ts +++ b/apps/backend/src/domain/services/rate-offer-generator.service.spec.ts @@ -2,16 +2,8 @@ import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator. import { CsvRate } from '../entities/csv-rate.entity'; import { PortCode } from '../value-objects/port-code.vo'; import { ContainerType } from '../value-objects/container-type.vo'; -import { Money } from '../value-objects/money.vo'; +import { DateRange } from '../value-objects/date-range.vo'; -/** - * Test Suite for Rate Offer Generator Service - * - * Vérifie que: - * - RAPID est le plus cher ET le plus rapide - * - ECONOMIC est le moins cher ET le plus lent - * - STANDARD est au milieu en prix et transit time - */ describe('RateOfferGeneratorService', () => { let service: RateOfferGeneratorService; let mockRate: CsvRate; @@ -19,415 +11,226 @@ describe('RateOfferGeneratorService', () => { beforeEach(() => { service = new RateOfferGeneratorService(); - // Créer un tarif de base pour les tests - // Prix: 1000 USD / 900 EUR, Transit: 20 jours + // Mock minimal CsvRate compatible with new schema mockRate = { companyName: 'Test Carrier', companyEmail: 'test@carrier.com', - origin: PortCode.create('FRPAR'), - destination: PortCode.create('USNYC'), + originCFS: 'Fos Sur Mer', + originCode: PortCode.create('FRFOS'), + portOfLoading: 'FOS SUR MER', + routing: 'Direct', + destinationCFS: 'New York', + destinationCode: PortCode.create('USNYC'), + destinationCountry: 'USA', containerType: ContainerType.create('LCL'), - volumeRange: { minCBM: 1, maxCBM: 10 }, - weightRange: { minKG: 100, maxKG: 5000 }, - palletCount: 0, - pricing: { - pricePerCBM: 100, - pricePerKG: 0.5, - basePriceUSD: Money.create(1000, 'USD'), - basePriceEUR: Money.create(900, 'EUR'), + freight: { + freightCurrency: 'USD', + freightRatePerCBM: 50, + freightMinimum: 500, }, - currency: 'USD', - hasSurcharges: false, - surchargeBAF: null, - surchargeCAF: null, - surchargeDetails: null, + fob: { + fobCurrency: 'EUR', + fobDocumentation: 55, + fobISPS: 18, + fobHandling: 22, + fobHandlingUnit: 'W', + fobHandlingMinimum: 110, + fobSolas: 15, + fobCustoms: 85, + fobAMS_ACI: 35, + fobISF5: 0, + fobDGAdmin: 50, + }, + dgSurcharge: { + dgSurchargeCurrency: 'EUR', + dgSurchargeRate: 20, + dgSurchargeUnit: 'UP', + dgSurchargeMin: 50, + }, + remarks: '', + frequency: 'Weekly', transitDays: 20, - validity: { - getStartDate: () => new Date('2024-01-01'), - getEndDate: () => new Date('2024-12-31'), - }, + validity: DateRange.create(new Date('2026-01-01'), new Date('2026-12-31'), true), isValidForDate: () => true, + isCurrentlyValid: () => true, matchesRoute: () => true, - matchesVolume: () => true, - matchesPalletCount: () => true, - getPriceInCurrency: () => Money.create(1000, 'USD'), - isAllInPrice: () => true, - getSurchargeDetails: () => null, + isDgAccepted: () => true, + isDgOnRequest: () => false, + isDirectRoute: () => true, + getFrequencyScore: () => 4, + getRouteDescription: () => 'FRFOS → USNYC', + getSummary: () => 'Test Carrier: FRFOS → USNYC', + toString: () => 'Test Carrier: FRFOS → USNYC', } as any; }); describe('generateOffers', () => { - it('devrait générer exactement 3 offres (RAPID, STANDARD, ECONOMIC)', () => { + it('generates exactly 3 offers (RAPID, STANDARD, ECONOMIC)', () => { const offers = service.generateOffers(mockRate); - expect(offers).toHaveLength(3); expect(offers.map(o => o.serviceLevel)).toEqual( expect.arrayContaining([ServiceLevel.RAPID, ServiceLevel.STANDARD, ServiceLevel.ECONOMIC]) ); }); - it('ECONOMIC doit être le moins cher', () => { + it('ECONOMIC has the lowest price multiplier (0.85)', () => { const offers = service.generateOffers(mockRate); - - const 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); - - // 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); + const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!; + expect(economic.priceMultiplier).toBe(0.85); + expect(economic.priceAdjustmentPercent).toBe(-15); }); - it('RAPID doit être le plus cher', () => { + it('RAPID has the highest price multiplier (1.2)', () => { const offers = service.generateOffers(mockRate); - - const 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); - - // 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); + const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!; + expect(rapid.priceMultiplier).toBe(1.2); + expect(rapid.priceAdjustmentPercent).toBe(20); }); - it("STANDARD doit avoir le prix de base (pas d'ajustement)", () => { + it('STANDARD has no price adjustment (multiplier = 1.0)', () => { const offers = service.generateOffers(mockRate); - - const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD); - - // 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); + const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!; + expect(standard.priceMultiplier).toBe(1.0); + expect(standard.priceAdjustmentPercent).toBe(0); }); - it('RAPID doit être le plus rapide (moins de jours de transit)', () => { + it('RAPID has the shortest transit time', () => { const offers = service.generateOffers(mockRate); + const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!; + const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!; + const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!; - const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC); - const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD); - const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID); - - // 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); + expect(rapid.adjustedTransitDays).toBeLessThan(standard.adjustedTransitDays); + expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays); + // 20 * 0.70 = 14 + expect(rapid.adjustedTransitDays).toBe(14); }); - it('ECONOMIC doit être le plus lent (plus de jours de transit)', () => { + it('ECONOMIC has the longest transit time', () => { const offers = service.generateOffers(mockRate); + const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!; + const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!; - const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC); - const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD); - 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); + expect(economic.adjustedTransitDays).toBeGreaterThan(standard.adjustedTransitDays); + // 20 * 1.50 = 30 + expect(economic.adjustedTransitDays).toBe(30); }); - it("STANDARD doit avoir le transit time de base (pas d'ajustement)", () => { + it('STANDARD has no transit adjustment', () => { const offers = service.generateOffers(mockRate); - - const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD); - - // STANDARD doit avoir le transit time de base - expect(standard!.adjustedTransitDays).toBe(20); - expect(standard!.transitAdjustmentPercent).toBe(0); + const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!; + expect(standard.adjustedTransitDays).toBe(20); + expect(standard.transitAdjustmentPercent).toBe(0); }); - it('les offres doivent être triées par prix croissant (ECONOMIC -> STANDARD -> RAPID)', () => { + it('offers are sorted by priceMultiplier (ECONOMIC → STANDARD → RAPID)', () => { const offers = service.generateOffers(mockRate); - expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC); expect(offers[1].serviceLevel).toBe(ServiceLevel.STANDARD); expect(offers[2].serviceLevel).toBe(ServiceLevel.RAPID); - - // Vérifier que les prix sont dans l'ordre croissant - expect(offers[0].adjustedPriceUSD).toBeLessThan(offers[1].adjustedPriceUSD); - expect(offers[1].adjustedPriceUSD).toBeLessThan(offers[2].adjustedPriceUSD); }); - it('doit conserver les informations originales du tarif', () => { - const offers = service.generateOffers(mockRate); + it('clamps transit time to minimum (5 days)', () => { + const shortTransitRate = { ...mockRate, transitDays: 3 } as any; + const offers = service.generateOffers(shortTransitRate); + const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!; + expect(rapid.adjustedTransitDays).toBe(5); + }); + it('clamps transit time to maximum (90 days)', () => { + const longTransitRate = { ...mockRate, transitDays: 80 } as any; + const offers = service.generateOffers(longTransitRate); + const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!; + expect(economic.adjustedTransitDays).toBe(90); + }); + + it('preserves the original rate reference', () => { + const offers = service.generateOffers(mockRate); for (const offer of offers) { expect(offer.rate).toBe(mockRate); - expect(offer.originalPriceUSD).toBe(1000); - expect(offer.originalPriceEUR).toBe(900); 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', () => { - it('doit générer 3 offres par tarif', () => { - const rate1 = mockRate; - const rate2 = { - ...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'); + it('generates 3 offers per rate', () => { + const rate2 = { ...mockRate, companyName: 'Another Carrier' } as any; + const offers = service.generateOffersForRates([mockRate, rate2]); + expect(offers).toHaveLength(6); }); }); describe('generateOffersForServiceLevel', () => { - it('doit générer uniquement les offres RAPID', () => { + it('generates only RAPID offers', () => { const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.RAPID); - expect(offers).toHaveLength(1); expect(offers[0].serviceLevel).toBe(ServiceLevel.RAPID); }); - it('doit générer uniquement les offres ECONOMIC', () => { + it('generates only ECONOMIC offers', () => { const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.ECONOMIC); - expect(offers).toHaveLength(1); 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', () => { - it('doit retourner la meilleure offre de chaque niveau de service', () => { - const rate1 = mockRate; - const rate2 = { - ...mockRate, - pricing: { - ...mockRate.pricing, - basePriceUSD: Money.create(800, 'USD'), - }, - } as any; - - const best = service.getBestOffersPerServiceLevel([rate1, rate2]); - + it('returns one offer per service level', () => { + const best = service.getBestOffersPerServiceLevel([mockRate]); expect(best.rapid).not.toBeNull(); expect(best.standard).not.toBeNull(); expect(best.economic).not.toBeNull(); + }); - // Toutes doivent provenir du rate2 (moins cher) - expect(best.rapid!.originalPriceUSD).toBe(800); - expect(best.standard!.originalPriceUSD).toBe(800); - expect(best.economic!.originalPriceUSD).toBe(800); + it('returns null for all levels when no rates', () => { + const best = service.getBestOffersPerServiceLevel([]); + expect(best.rapid).toBeNull(); + expect(best.standard).toBeNull(); + expect(best.economic).toBeNull(); }); }); describe('isRateEligible', () => { - it('doit accepter un tarif valide', () => { + it('accepts a valid rate', () => { expect(service.isRateEligible(mockRate)).toBe(true); }); - it('doit rejeter un tarif avec transit time = 0', () => { - const invalidRate = { ...mockRate, transitDays: 0 } as any; - expect(service.isRateEligible(invalidRate)).toBe(false); + it('rejects a rate with transitDays = 0', () => { + const invalid = { ...mockRate, transitDays: 0 } as any; + expect(service.isRateEligible(invalid)).toBe(false); }); - it('doit rejeter un tarif avec prix = 0', () => { - const invalidRate = { + it('rejects a rate with freightRatePerCBM = 0 and freightMinimum = 0', () => { + const invalid = { ...mockRate, - pricing: { - ...mockRate.pricing, - basePriceUSD: Money.create(0, 'USD'), - }, + freight: { ...mockRate.freight, freightRatePerCBM: 0, freightMinimum: 0 }, } as any; - expect(service.isRateEligible(invalidRate)).toBe(false); + expect(service.isRateEligible(invalid)).toBe(false); }); - it('doit rejeter un tarif expiré', () => { - const expiredRate = { - ...mockRate, - isValidForDate: () => false, - } as any; - expect(service.isRateEligible(expiredRate)).toBe(false); + it('rejects an expired rate', () => { + const expired = { ...mockRate, isValidForDate: () => false } as any; + expect(service.isRateEligible(expired)).toBe(false); }); }); - describe('filterEligibleRates', () => { - it('doit filtrer les tarifs invalides', () => { - const validRate = mockRate; - const invalidRate1 = { ...mockRate, transitDays: 0 } as any; - const invalidRate2 = { - ...mockRate, - pricing: { - ...mockRate.pricing, - basePriceUSD: Money.create(0, 'USD'), - }, - } as any; - - const eligibleRates = service.filterEligibleRates([validRate, invalidRate1, invalidRate2]); - - expect(eligibleRates).toHaveLength(1); - expect(eligibleRates[0]).toBe(validRate); - }); - }); - - describe('Validation de la logique métier', () => { - it('RAPID doit TOUJOURS être plus cher que ECONOMIC', () => { - // Test avec différents prix de base - const prices = [100, 500, 1000, 5000, 10000]; - - for (const price of prices) { - const rate = { - ...mockRate, - pricing: { - ...mockRate.pricing, - basePriceUSD: Money.create(price, 'USD'), - }, - } as any; - - const offers = service.generateOffers(rate); - const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!; - const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!; - - expect(rapid.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD); - } + describe('Business logic invariants', () => { + it('RAPID priceMultiplier always > ECONOMIC priceMultiplier', () => { + const offers = service.generateOffers(mockRate); + const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!; + const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!; + expect(rapid.priceMultiplier).toBeGreaterThan(economic.priceMultiplier); }); - 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) { + it('RAPID transit always < ECONOMIC transit for different base days', () => { + for (const days of [5, 10, 20, 30, 60]) { 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); } }); - - 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); - }); }); }); diff --git a/apps/backend/src/domain/services/rate-offer-generator.service.ts b/apps/backend/src/domain/services/rate-offer-generator.service.ts index 4a74ac5..43fe4f5 100644 --- a/apps/backend/src/domain/services/rate-offer-generator.service.ts +++ b/apps/backend/src/domain/services/rate-offer-generator.service.ts @@ -3,9 +3,9 @@ import { CsvRate } from '../entities/csv-rate.entity'; /** * Service Level Types * - * - RAPID: Offre la plus chère + la plus rapide (transit time réduit) - * - STANDARD: Offre standard (prix et transit time de base) - * - ECONOMIC: Offre la moins chère + la plus lente (transit time augmenté) + * - RAPID : +20% price, -30% transit (express, priority) + * - STANDARD : base price and transit + * - ECONOMIC : -15% price, +50% transit (cheapest, slowest) */ export enum ServiceLevel { RAPID = 'RAPID', @@ -13,243 +13,110 @@ export enum ServiceLevel { ECONOMIC = 'ECONOMIC', } -/** - * Rate Offer - Variante d'un tarif avec un niveau de service - */ export interface RateOffer { rate: CsvRate; serviceLevel: ServiceLevel; - adjustedPriceUSD: number; - adjustedPriceEUR: number; + priceMultiplier: number; adjustedTransitDays: number; - originalPriceUSD: number; - originalPriceEUR: number; originalTransitDays: number; priceAdjustmentPercent: number; transitAdjustmentPercent: number; description: string; } -/** - * Configuration pour les ajustements de prix et transit par niveau de service - */ interface ServiceLevelConfig { - priceMultiplier: number; // Multiplicateur de prix (1.0 = pas de changement) - transitMultiplier: number; // Multiplicateur de transit time (1.0 = pas de changement) + priceMultiplier: number; + transitMultiplier: number; description: string; } /** - * Rate Offer Generator Service + * Generates RAPID / STANDARD / ECONOMIC variants for a given CSV rate. * - * Service du domaine qui génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV. - * - * 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 + * Price adjustment is applied to the total calculated price in the search service — + * this service only stores the multiplier and the adjusted transit time. */ 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.RAPID]: { - priceMultiplier: 1.2, // +20% du prix de base - transitMultiplier: 0.7, // -30% du temps de transit (plus rapide) - description: 'Express - Livraison rapide avec service prioritaire', + priceMultiplier: 1.2, + transitMultiplier: 0.7, + description: 'Express — Livraison rapide avec service prioritaire', }, [ServiceLevel.STANDARD]: { - priceMultiplier: 1.0, // Prix de base (pas de changement) - transitMultiplier: 1.0, // Transit time de base (pas de changement) - description: 'Standard - Service régulier au meilleur rapport qualité/prix', + priceMultiplier: 1.0, + transitMultiplier: 1.0, + description: 'Standard — Service régulier au meilleur rapport qualité/prix', }, [ServiceLevel.ECONOMIC]: { - priceMultiplier: 0.85, // -15% du prix de base - transitMultiplier: 1.5, // +50% du temps de transit (plus lent) - description: 'Économique - Tarif réduit avec délai étendu', + priceMultiplier: 0.85, + transitMultiplier: 1.5, + 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; - - /** - * 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; - /** - * 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[] { 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)) { const config = this.SERVICE_LEVEL_CONFIGS[serviceLevel]; - - // 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); + const rawTransit = rate.transitDays * config.transitMultiplier; + const adjustedTransitDays = this.clampTransit(Math.round(rawTransit)); offers.push({ rate, serviceLevel, - adjustedPriceUSD, - adjustedPriceEUR, + priceMultiplier: config.priceMultiplier, adjustedTransitDays, - originalPriceUSD: basePriceUSD, - originalPriceEUR: basePriceEUR, - originalTransitDays: baseTransitDays, - priceAdjustmentPercent, - transitAdjustmentPercent, + originalTransitDays: rate.transitDays, + priceAdjustmentPercent: Math.round((config.priceMultiplier - 1) * 100), + transitAdjustmentPercent: Math.round((config.transitMultiplier - 1) * 100), description: config.description, }); } - // Trier par prix croissant: ECONOMIC (moins cher) -> STANDARD -> RAPID (plus cher) - return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD); + // ECONOMIC → STANDARD → RAPID (cheapest first) + return offers.sort((a, b) => a.priceMultiplier - b.priceMultiplier); } - /** - * Génère plusieurs offres pour une liste de tarifs - * - * @param rates - Liste de tarifs CSV - * @returns Liste de toutes les offres générées (3 par tarif), triées par prix - */ generateOffersForRates(rates: CsvRate[]): RateOffer[] { - 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); + return rates.flatMap(rate => this.generateOffers(rate)); } - /** - * 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[] { - const offers: RateOffer[] = []; - - 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); + return rates + .map(rate => this.generateOffers(rate).find(o => o.serviceLevel === serviceLevel)!) + .filter(Boolean); } - /** - * 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[]): { rapid: RateOffer | null; standard: RateOffer | null; economic: RateOffer | null; } { return { - rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] || null, - standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] || null, - economic: this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC)[0] || null, + rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] ?? null, + standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[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 { if (rate.transitDays <= 0) return false; - if (rate.pricing.basePriceUSD.getAmount() <= 0) return false; + // A rate is usable if it has a freight rate or at least a freight minimum + if (rate.freight.freightRatePerCBM <= 0 && rate.freight.freightMinimum <= 0) return false; if (!rate.isValidForDate(new Date())) return false; - return true; } - /** - * Filtre les tarifs éligibles pour la génération d'offres - */ filterEligibleRates(rates: CsvRate[]): CsvRate[] { 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)); + } } diff --git a/apps/backend/src/infrastructure/carriers/csv-loader/csv-converter.service.ts b/apps/backend/src/infrastructure/carriers/csv-loader/csv-converter.service.ts index 40d36c4..48a17a4 100644 --- a/apps/backend/src/infrastructure/carriers/csv-loader/csv-converter.service.ts +++ b/apps/backend/src/infrastructure/carriers/csv-loader/csv-converter.service.ts @@ -5,41 +5,58 @@ import * as path from 'path'; /** * CSV Converter Service * - * Détecte automatiquement le format du CSV et convertit au format attendu - * Supporte: - * - Format standard Xpeditis - * - Format "Frais FOB FRET" + * Detects and converts CSV files to the standard 33-column Xpeditis format. + * + * Standard format columns (33): + * companyName, companyEmail, originCFS, originCode, portOfLoading, routing, + * destinationCFS, destinationCode, destinationCountry, containerType, + * freightCurrency, freightRatePerCBM, freightMinimum, + * fobCurrency, fobDocumentation, fobISPS, fobHandling, fobHandlingUnit, + * fobHandlingMinimum, fobSolas, fobCustoms, fobAMS_ACI, fobISF5, fobDGAdmin, + * dgSurchargeCurrency, dgSurchargeRate, dgSurchargeUnit, dgSurchargeMin, + * remarks, frequency, transitDays, validFrom, validUntil */ @Injectable() export class CsvConverterService { private readonly logger = new Logger(CsvConverterService.name); - // Headers du format standard attendu private readonly STANDARD_HEADERS = [ 'companyName', - 'origin', - 'destination', + 'companyEmail', + 'originCFS', + 'originCode', + 'portOfLoading', + 'routing', + 'destinationCFS', + 'destinationCode', + 'destinationCountry', 'containerType', - 'minVolumeCBM', - 'maxVolumeCBM', - 'minWeightKG', - 'maxWeightKG', - 'palletCount', - 'pricePerCBM', - 'pricePerKG', - 'basePriceUSD', - 'basePriceEUR', - 'currency', - 'hasSurcharges', - 'surchargeBAF', - 'surchargeCAF', - 'surchargeDetails', + 'freightCurrency', + 'freightRatePerCBM', + 'freightMinimum', + 'fobCurrency', + 'fobDocumentation', + 'fobISPS', + 'fobHandling', + 'fobHandlingUnit', + 'fobHandlingMinimum', + 'fobSolas', + 'fobCustoms', + 'fobAMS_ACI', + 'fobISF5', + 'fobDGAdmin', + 'dgSurchargeCurrency', + 'dgSurchargeRate', + 'dgSurchargeUnit', + 'dgSurchargeMin', + 'remarks', + 'frequency', 'transitDays', 'validFrom', 'validUntil', ]; - // Headers du format "Frais FOB FRET" + // Legacy "Frais FOB FRET" format indicators (older Excel exports) private readonly FOB_FRET_HEADERS = [ 'Origine UN code', 'Destination UN code', @@ -49,259 +66,32 @@ export class CsvConverterService { 'Transit time', ]; - /** - * Parse une ligne CSV en gérant les champs entre guillemets - */ - private parseCSVLine(line: string): string[] { - const result: string[] = []; - let current = ''; - let inQuotes = false; - - for (let i = 0; i < line.length; i++) { - const char = line[i]; - - if (char === '"') { - inQuotes = !inQuotes; - } else if (char === ',' && !inQuotes) { - result.push(current.trim()); - current = ''; - } else { - current += char; - } - } - result.push(current.trim()); - - return result; - } - - /** - * Détecte le format du CSV - */ async detectFormat(filePath: string): Promise<'STANDARD' | 'FOB_FRET' | 'UNKNOWN'> { try { const content = await fs.readFile(filePath, 'utf-8'); - const lines = content.split('\n').filter(line => line.trim()); + const lines = content.split('\n').filter(l => l.trim()); + if (lines.length === 0) return 'UNKNOWN'; - if (lines.length === 0) { - return 'UNKNOWN'; - } - - // Vérifier les 2 premières lignes (parfois la vraie ligne d'en-tête est la ligne 2) for (let i = 0; i < Math.min(2, lines.length); i++) { const headers = this.parseCSVLine(lines[i]); - - // Vérifier format standard - const hasStandardHeaders = this.STANDARD_HEADERS.some(h => headers.includes(h)); - if (hasStandardHeaders) { - return 'STANDARD'; - } - - // Vérifier format FOB FRET - const hasFobFretHeaders = this.FOB_FRET_HEADERS.some(h => headers.includes(h)); - if (hasFobFretHeaders) { - return 'FOB_FRET'; - } + if (this.STANDARD_HEADERS.some(h => headers.includes(h))) return 'STANDARD'; + if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) return 'FOB_FRET'; } - return 'UNKNOWN'; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - this.logger.error(`Error detecting CSV format: ${errorMessage}`); + } catch { return 'UNKNOWN'; } } - /** - * Calcule les surcharges à partir des colonnes FOB - */ - private calculateSurcharges(row: Record): 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, companyName: string): Record { - 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[] = []; - for (let i = headerLineIndex + 1; i < lines.length; i++) { - const values = this.parseCSVLine(lines[i]); - const row: Record = {}; - - headers.forEach((header, index) => { - row[header] = values[index] || ''; - }); - - // Vérifier que la ligne a des données valides - if (row['Origine UN code'] && row['Destination UN code']) { - dataRows.push(row); - } - } - - this.logger.log(`Found ${dataRows.length} valid data rows`); - - // Convertir les lignes - const convertedRows = dataRows.map(row => this.convertFobFretRow(row, companyName)); - - // Générer le CSV de sortie - const outputLines: string[] = [this.STANDARD_HEADERS.join(',')]; - - convertedRows.forEach(row => { - const values = this.STANDARD_HEADERS.map(header => { - const value = row[header]; - - // Échapper les virgules et quotes - if ( - typeof value === 'string' && - (value.includes(',') || value.includes('"') || value.includes('\n')) - ) { - return `"${value.replace(/"/g, '""')}"`; - } - return value; - }); - outputLines.push(values.join(',')); - }); - - // Écrire le fichier converti (garder le chemin absolu) - const outputPath = inputPath.replace('.csv', '-converted.csv'); - const absoluteOutputPath = path.isAbsolute(outputPath) - ? outputPath - : path.resolve(process.cwd(), outputPath); - - await fs.writeFile(absoluteOutputPath, outputLines.join('\n'), 'utf-8'); - - this.logger.log(`Conversion completed: ${absoluteOutputPath} (${convertedRows.length} rows)`); - - return { - outputPath: absoluteOutputPath, - rowsConverted: convertedRows.length, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - const errorStack = error instanceof Error ? error.stack : undefined; - this.logger.error(`Error converting CSV: ${errorMessage}`, errorStack); - throw new Error(`CSV conversion failed: ${errorMessage}`); - } - } - - /** - * Convertit automatiquement un CSV si nécessaire - */ async autoConvert( inputPath: string, companyName: string ): Promise<{ convertedPath: string; wasConverted: boolean; rowsConverted?: number }> { const format = await this.detectFormat(inputPath); - - this.logger.log(`Detected CSV format: ${format}`); + this.logger.log(`Detected CSV format: ${format} for ${inputPath}`); if (format === 'STANDARD') { - return { - convertedPath: inputPath, - wasConverted: false, - }; + return { convertedPath: inputPath, wasConverted: false }; } if (format === 'FOB_FRET') { @@ -313,6 +103,134 @@ export class CsvConverterService { }; } - throw new Error(`Unknown CSV format. Please provide a valid CSV file.`); + throw new Error( + 'Unknown CSV format. Please provide a file matching the standard 33-column schema.' + ); + } + + async convertFobFretToStandard( + inputPath: string, + companyName: string + ): Promise<{ outputPath: string; rowsConverted: number }> { + this.logger.log(`Converting legacy FOB FRET CSV: ${inputPath}`); + + const content = await fs.readFile(inputPath, 'utf-8'); + const lines = content.split('\n').filter(l => l.trim()); + + if (lines.length < 2) throw new Error('CSV file is empty or has no data rows'); + + // Find the header line + let headerLineIndex = 0; + for (let i = 0; i < Math.min(2, lines.length); i++) { + const headers = this.parseCSVLine(lines[i]); + if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) { + headerLineIndex = i; + break; + } + } + + const headers = this.parseCSVLine(lines[headerLineIndex]); + + const dataRows: Record[] = []; + for (let i = headerLineIndex + 1; i < lines.length; i++) { + const values = this.parseCSVLine(lines[i]); + const row: Record = {}; + 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, companyName: string): Record { + 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; } } diff --git a/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts b/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts index 87d6943..96c211b 100644 --- a/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts +++ b/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts @@ -4,61 +4,109 @@ import { parse } from 'csv-parse/sync'; import * as fs from 'fs/promises'; import * as path from 'path'; import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port'; -import { CsvRate } from '@domain/entities/csv-rate.entity'; +import { + CsvRate, + FreightPricing, + FobCharges, + DgSurchargeInfo, + DgSurchargeValue, + HandlingUnit, + FrequencyType, +} from '@domain/entities/csv-rate.entity'; import { PortCode } from '@domain/value-objects/port-code.vo'; import { 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 { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter'; import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository'; /** - * CSV Row Interface - * Maps to CSV file structure + * Standardized 33-column CSV row. + * All suppliers share this exact schema. */ interface CsvRow { + // Supplier identity companyName: string; - origin: string; - destination: string; + companyEmail: string; + // Route geography + originCFS: string; + originCode: string; + portOfLoading: string; + routing: string; + destinationCFS: string; + destinationCode: string; + destinationCountry: string; + // Container containerType: string; - minVolumeCBM: string; - maxVolumeCBM: string; - minWeightKG: string; - maxWeightKG: string; - palletCount: string; - pricePerCBM: string; - pricePerKG: string; - basePriceUSD: string; - basePriceEUR: string; - currency: string; - hasSurcharges: string; - surchargeBAF?: string; - surchargeCAF?: string; - surchargeDetails?: string; + // Freight + freightCurrency: string; + freightRatePerCBM: string; + freightMinimum: string; + // FOB charges + fobCurrency: string; + fobDocumentation: string; + fobISPS: string; + fobHandling: string; + fobHandlingUnit: string; + fobHandlingMinimum: string; + fobSolas: string; + fobCustoms: string; + fobAMS_ACI: string; + fobISF5: string; + fobDGAdmin: string; + // DG surcharge + dgSurchargeCurrency: string; + dgSurchargeRate: string; + dgSurchargeUnit: string; + dgSurchargeMin: string; + // Metadata + remarks: string; + frequency: string; transitDays: string; validFrom: string; validUntil: string; } -/** - * CSV Rate Loader Adapter - * - * Infrastructure adapter for loading shipping rates from CSV files. - * Implements CsvRateLoaderPort interface. - * - * Features: - * - CSV parsing with validation - * - Mapping CSV rows to domain entities - * - Error handling and logging - * - File system operations - */ +const REQUIRED_COLUMNS = [ + 'companyName', + 'companyEmail', + 'originCFS', + 'originCode', + 'portOfLoading', + 'routing', + 'destinationCFS', + 'destinationCode', + 'destinationCountry', + 'containerType', + 'freightCurrency', + 'freightRatePerCBM', + 'freightMinimum', + 'fobCurrency', + 'fobDocumentation', + 'fobISPS', + 'fobHandling', + 'fobHandlingUnit', + 'fobHandlingMinimum', + 'fobSolas', + 'fobCustoms', + 'fobAMS_ACI', + 'fobISF5', + 'fobDGAdmin', + 'dgSurchargeCurrency', + 'dgSurchargeRate', + 'dgSurchargeUnit', + 'dgSurchargeMin', + 'remarks', + 'frequency', + 'transitDays', + 'validFrom', + 'validUntil', +]; + @Injectable() export class CsvRateLoaderAdapter implements CsvRateLoaderPort { private readonly logger = new Logger(CsvRateLoaderAdapter.name); private readonly csvDirectory: string; - // Company name to CSV file mapping private readonly companyFileMapping: Map = new Map([ ['SSC Consolidation', 'ssc-consolidation.csv'], ['ECU Worldwide', 'ecu-worldwide.csv'], @@ -71,10 +119,6 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort { @Optional() private readonly configService?: ConfigService, @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( process.cwd(), 'src', @@ -84,10 +128,6 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort { 'rates' ); 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( @@ -95,49 +135,32 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort { companyEmail: string, companyNameOverride?: string ): Promise { - this.logger.log( - `Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})` - ); + this.logger.log(`Loading rates from CSV: ${filePath}`); try { let fileContent: string; - // Try to load from MinIO first if configured if (this.s3Storage && this.configService && this.csvConfigRepository && companyNameOverride) { try { const config = await this.csvConfigRepository.findByCompanyName(companyNameOverride); const minioObjectKey = config?.metadata?.minioObjectKey as string | undefined; - if (minioObjectKey) { const bucket = this.configService.get('AWS_S3_BUCKET', 'xpeditis-csv-rates'); - this.logger.log(`📥 Loading CSV from MinIO: ${bucket}/${minioObjectKey}`); - const buffer = await this.s3Storage.download({ bucket, key: minioObjectKey }); fileContent = buffer.toString('utf-8'); - this.logger.log(`✅ Successfully loaded CSV from MinIO`); } else { - // Fallback to local file - throw new Error('No MinIO object key found, using local file'); + throw new Error('No MinIO object key'); } } catch (minioError: any) { - this.logger.warn( - `⚠️ 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); + this.logger.warn(`MinIO unavailable: ${minioError.message}. Using local file.`); + const fullPath = this.resolvePath(filePath); fileContent = await fs.readFile(fullPath, 'utf-8'); } } else { - // Read from local file system - const fullPath = path.isAbsolute(filePath) - ? filePath - : path.join(this.csvDirectory, filePath); + const fullPath = this.resolvePath(filePath); fileContent = await fs.readFile(fullPath, 'utf-8'); } - // Parse CSV const records: CsvRow[] = parse(fileContent, { columns: true, skip_empty_lines: true, @@ -145,62 +168,48 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort { }); this.logger.log(`Parsed ${records.length} rows from ${filePath}`); - - // Validate structure this.validateCsvStructure(records); - // Map to domain entities const rates = records.map((record, index) => { try { return this.mapToCsvRate(record, companyEmail, companyNameOverride); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`); - throw new Error(`Invalid data in row ${index + 1} of ${filePath}: ${errorMessage}`); + const msg = error instanceof Error ? error.message : String(error); + throw new Error(`Row ${index + 1} in ${filePath}: ${msg}`); } }); - this.logger.log(`Successfully loaded ${rates.length} rates from ${filePath}`); + this.logger.log(`Loaded ${rates.length} rates from ${filePath}`); return rates; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to load CSV file ${filePath}: ${errorMessage}`); - throw new Error(`CSV loading failed for ${filePath}: ${errorMessage}`); + const msg = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to load ${filePath}: ${msg}`); + throw new Error(`CSV loading failed for ${filePath}: ${msg}`); } } async loadRatesByCompany(companyName: string): Promise { const fileName = this.companyFileMapping.get(companyName); - if (!fileName) { - this.logger.warn(`No CSV file configured for company: ${companyName}`); + this.logger.warn(`No CSV file for company: ${companyName}`); return []; } - - // 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); + const email = `info@${companyName.toLowerCase().replace(/\s+/g, '-')}.com`; + return this.loadRatesFromCsv(fileName, email); } async validateCsvFile( filePath: string ): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> { const errors: string[] = []; - try { - const fullPath = path.isAbsolute(filePath) - ? filePath - : path.join(this.csvDirectory, filePath); - - // Check if file exists + const fullPath = this.resolvePath(filePath); try { await fs.access(fullPath); } catch { - errors.push(`File not found: ${filePath}`); - return { valid: false, errors }; + return { valid: false, errors: [`File not found: ${filePath}`] }; } - // Read and parse const fileContent = await fs.readFile(fullPath, 'utf-8'); const records: CsvRow[] = parse(fileContent, { columns: true, @@ -209,200 +218,154 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort { }); if (records.length === 0) { - errors.push('CSV file is empty'); - return { valid: false, errors, rowCount: 0 }; + return { valid: false, errors: ['CSV file is empty'], rowCount: 0 }; } - // Validate structure try { this.validateCsvStructure(records); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - errors.push(errorMessage); + } catch (e) { + errors.push(e instanceof Error ? e.message : String(e)); } - // Validate each row (use dummy email for validation) records.forEach((record, index) => { try { this.mapToCsvRate(record, 'validation@example.com'); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - errors.push(`Row ${index + 1}: ${errorMessage}`); + } catch (e) { + errors.push(`Row ${index + 1}: ${e instanceof Error ? e.message : String(e)}`); } }); + return { valid: errors.length === 0, errors, rowCount: records.length }; + } catch (e) { return { - valid: errors.length === 0, - errors, - rowCount: records.length, + valid: false, + errors: [`Validation failed: ${e instanceof Error ? e.message : String(e)}`], }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - errors.push(`Validation failed: ${errorMessage}`); - return { valid: false, errors }; } } async getAvailableCsvFiles(): Promise { try { - // If MinIO/S3 is configured, list files from there - if (this.s3Storage && this.configService && this.csvConfigRepository) { + if (this.s3Storage && this.csvConfigRepository) { try { const configs = await this.csvConfigRepository.findAll(); const minioFiles = configs - .filter(config => config.metadata?.minioObjectKey) - .map(config => config.metadata?.minioObjectKey as string); - - if (minioFiles.length > 0) { - 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( - `⚠️ Failed to list MinIO files: ${minioError.message}. Falling back to local files.` - ); + .filter(c => c.metadata?.minioObjectKey) + .map(c => c.metadata?.minioObjectKey as string); + if (minioFiles.length > 0) return minioFiles; + } catch { + // fall through to local } } - // Fallback: list from local file system try { await fs.access(this.csvDirectory); } catch { - this.logger.warn(`CSV directory does not exist: ${this.csvDirectory}`); return []; } const files = await fs.readdir(this.csvDirectory); - return files.filter(file => file.endsWith('.csv')); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to list CSV files: ${errorMessage}`); + return files.filter(f => f.endsWith('.csv')); + } catch { return []; } } - /** - * Validate that CSV has all required columns - */ + private resolvePath(filePath: string): string { + return path.isAbsolute(filePath) ? filePath : path.join(this.csvDirectory, filePath); + } + 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'); - } - + 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(', ')}`); + const missing = REQUIRED_COLUMNS.filter(col => !(col in firstRecord)); + if (missing.length > 0) { + throw new Error(`Missing required columns: ${missing.join(', ')}`); } } - /** - * Map CSV row to CsvRate domain entity - */ - private mapToCsvRate( - record: CsvRow, - companyEmail: string, - companyNameOverride?: string - ): CsvRate { - // Parse surcharges - const surcharges = this.parseSurcharges(record); + 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(); - // Create DateRange - const validFrom = new Date(record.validFrom); - const validUntil = new Date(record.validUntil); + 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); - // Use override company name if provided, otherwise use the one from CSV - const companyName = companyNameOverride || record.companyName.trim(); + const frequency = parseFrequency(r.frequency); - // 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), + 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 ); } +} - /** - * Parse surcharges from CSV row - */ - private parseSurcharges(record: CsvRow): Surcharge[] { - const hasSurcharges = record.hasSurcharges.toLowerCase() === 'true'; +function parseDgValue(raw: string): DgSurchargeValue { + if (!raw || raw.trim() === '') return 0; + const upper = raw.trim().toUpperCase(); + if (upper === 'ON REQUEST') return 'ON REQUEST'; + if (upper === 'NOT ACCEPTED') return 'NOT ACCEPTED'; + const num = parseFloat(raw); + return isNaN(num) ? 0 : num; +} - 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; +function parseFrequency(raw: string): FrequencyType { + switch (raw?.trim()) { + case 'Weekly': + return 'Weekly'; + case 'Bi-Weekly': + return 'Bi-Weekly'; + case 'Bi-Monthly': + return 'Bi-Monthly'; + case 'Monthly': + return 'Monthly'; + default: + return 'Weekly'; } } diff --git a/apps/frontend/app/[locale]/dashboard/booking/new/page.tsx b/apps/frontend/app/[locale]/dashboard/booking/new/page.tsx index 8254afb..08ca471 100644 --- a/apps/frontend/app/[locale]/dashboard/booking/new/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/booking/new/page.tsx @@ -21,9 +21,12 @@ interface BookingForm { volumeCBM: number; weightKG: number; palletCount: number; - priceUSD: number; - priceEUR: number; - primaryCurrency: 'USD' | 'EUR'; + freightTotal: number; + freightCurrency: string; + fobTotal: number; + fobCurrency: string; + primaryCurrency: string; + totalPriceForSorting: number; transitDays: number; containerType: string; @@ -61,9 +64,12 @@ function NewBookingPageContent() { volumeCBM: 0, weightKG: 0, palletCount: 0, - priceUSD: 0, - priceEUR: 0, - primaryCurrency: 'EUR', + freightTotal: 0, + freightCurrency: 'USD', + fobTotal: 0, + fobCurrency: 'EUR', + primaryCurrency: 'USD', + totalPriceForSorting: 0, transitDays: 0, containerType: '', documents: [], @@ -85,9 +91,12 @@ function NewBookingPageContent() { volumeCBM: parseFloat(searchParams.get('volumeCBM') || '0'), weightKG: parseFloat(searchParams.get('weightKG') || '0'), palletCount: parseInt(searchParams.get('palletCount') || '0'), - priceUSD: rateData.priceUSD, - priceEUR: rateData.priceEUR, - primaryCurrency: rateData.primaryCurrency as 'USD' | 'EUR', + freightTotal: rateData.priceBreakdown.totalFreight, + freightCurrency: rateData.priceBreakdown.freightCurrency, + fobTotal: rateData.priceBreakdown.totalFob, + fobCurrency: rateData.priceBreakdown.fobCurrency, + primaryCurrency: rateData.priceBreakdown.primaryCurrency || 'USD', + totalPriceForSorting: rateData.priceBreakdown.totalPriceForSorting, transitDays: rateData.transitDays, containerType: rateData.containerType, })); @@ -151,6 +160,14 @@ function NewBookingPageContent() { // Create FormData for multipart upload 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 formDataToSend.append('carrierName', formData.carrierName); formDataToSend.append('carrierEmail', formData.carrierEmail); @@ -159,8 +176,8 @@ function NewBookingPageContent() { formDataToSend.append('volumeCBM', formData.volumeCBM.toString()); formDataToSend.append('weightKG', formData.weightKG.toString()); formDataToSend.append('palletCount', formData.palletCount.toString()); - formDataToSend.append('priceUSD', formData.priceUSD.toString()); - formDataToSend.append('priceEUR', formData.priceEUR.toString()); + formDataToSend.append('priceUSD', priceUSD.toString()); + formDataToSend.append('priceEUR', priceEUR.toString()); formDataToSend.append('primaryCurrency', formData.primaryCurrency); formDataToSend.append('transitDays', formData.transitDays.toString()); formDataToSend.append('containerType', formData.containerType); @@ -346,22 +363,28 @@ function NewBookingPageContent() {

Prix estimé

-
+
-

Prix en EUR

-

- {formatPrice(formData.priceEUR, 'EUR')} +

Fret ({formData.freightCurrency})

+

+ {formatPrice(formData.freightTotal, formData.freightCurrency)}

- {formData.priceUSD > 0 && ( + {formData.fobTotal > 0 && (
-

Prix en USD

+

FOB ({formData.fobCurrency})

- {formatPrice(formData.priceUSD, 'USD')} + {formatPrice(formData.fobTotal, formData.fobCurrency)}

)}
+
+

Prix total

+

+ {formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')} +

+
@@ -562,10 +585,24 @@ function NewBookingPageContent() { {formData.documents.length} fichier(s) +
+ Fret : + + {formatPrice(formData.freightTotal, formData.freightCurrency)} + +
+ {formData.fobTotal > 0 && ( +
+ FOB : + + {formatPrice(formData.fobTotal, formData.fobCurrency)} + +
+ )}
Prix total : - {formatPrice(formData.priceEUR, 'EUR')} + {formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')}
diff --git a/apps/frontend/app/[locale]/dashboard/search-advanced/results/page.tsx b/apps/frontend/app/[locale]/dashboard/search-advanced/results/page.tsx index 4d0e56c..f26d2bb 100644 --- a/apps/frontend/app/[locale]/dashboard/search-advanced/results/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/search-advanced/results/page.tsx @@ -40,14 +40,7 @@ export default function SearchResultsPage() { destination, volumeCBM, weightKG, - palletCount, 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); @@ -83,8 +76,8 @@ export default function SearchResultsPage() { }; } - const sorted = [...results].sort((a, b) => a.priceEUR - b.priceEUR); - const fastest = [...results].sort((a, b) => a.transitDays - b.transitDays); + const sorted = [...results].sort((a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting); + const fastest = [...results].sort((a, b) => (a.adjustedTransitDays ?? a.transitDays) - (b.adjustedTransitDays ?? b.transitDays)); return { eco: sorted[0], @@ -281,7 +274,7 @@ export default function SearchResultsPage() {

{t('totalPrice')}

-

{formatPrice(card.option.priceEUR)}

+

{formatPrice(card.option.priceBreakdown.totalPriceForSorting)}

@@ -292,7 +285,7 @@ export default function SearchResultsPage() {
{t('transit')} - {t('transitDays', { days: card.option.transitDays })} + {t('transitDays', { days: card.option.adjustedTransitDays ?? card.option.transitDays })}
@@ -334,34 +327,32 @@ export default function SearchResultsPage() {

-

{formatPrice(result.priceEUR)}

+

{formatPrice(result.priceBreakdown.totalPriceForSorting)}

{t('totalPrice')}

-

{t('priceBreakdown.base')}

+

Fret ({result.priceBreakdown.freightCurrency})

- {formatPrice(result.priceBreakdown.basePrice)} + {formatPrice(result.priceBreakdown.totalFreight)}

-

{t('priceBreakdown.volume')}

+

FOB ({result.priceBreakdown.fobCurrency})

- {formatPrice(result.priceBreakdown.volumeCharge)} + {formatPrice(result.priceBreakdown.totalFob)}

-

{t('priceBreakdown.weight')}

-

- {formatPrice(result.priceBreakdown.weightCharge)} -

+

Routage

+

{result.routing}

{t('priceBreakdown.transit')}

- {t('transitDays', { days: result.transitDays })} + {t('transitDays', { days: result.adjustedTransitDays ?? result.transitDays })}

@@ -369,9 +360,14 @@ export default function SearchResultsPage() {
{t('validUntil', { date: new Date(result.validUntil).toLocaleDateString(dateLocale) })} - {result.hasSurcharges && ( + {result.dgSurchargeStatus === 'not_accepted' && ( - {t('surcharges')} + DG non accepté + + )} + {result.dgSurchargeStatus === 'on_request' && ( + + DG sur demande )}
diff --git a/apps/frontend/src/__tests__/hooks/useCsvRateSearch.test.tsx b/apps/frontend/src/__tests__/hooks/useCsvRateSearch.test.tsx index 37be54f..7cbd774 100644 --- a/apps/frontend/src/__tests__/hooks/useCsvRateSearch.test.tsx +++ b/apps/frontend/src/__tests__/hooks/useCsvRateSearch.test.tsx @@ -1,17 +1,17 @@ -import { renderHook, act, waitFor } from '@testing-library/react'; +import { renderHook, act } from '@testing-library/react'; import { useCsvRateSearch } from '@/hooks/useCsvRateSearch'; -import { searchCsvRates } from '@/lib/api/csv-rates'; -import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rate-filters'; +import { searchCsvRatesWithOffers } from '@/lib/api/rates'; +import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rates'; -jest.mock('@/lib/api/csv-rates', () => ({ - searchCsvRates: jest.fn(), +jest.mock('@/lib/api/rates', () => ({ + searchCsvRatesWithOffers: jest.fn(), })); -const mockSearchCsvRates = jest.mocked(searchCsvRates); +const mockSearchCsvRatesWithOffers = jest.mocked(searchCsvRatesWithOffers); const mockRequest: CsvRateSearchRequest = { - origin: 'Le Havre', - destination: 'Shanghai', + origin: 'FRLEH', + destination: 'CNSHA', volumeCBM: 10, weightKG: 5000, }; @@ -19,24 +19,58 @@ const mockRequest: CsvRateSearchRequest = { const mockResponse: CsvRateSearchResponse = { results: [ { - companyName: 'Maersk', - origin: 'Le Havre', - destination: 'Shanghai', - containerType: '40ft', - priceUSD: 2500, - priceEUR: 2300, - primaryCurrency: 'USD', - hasSurcharges: false, - surchargeDetails: null, - transitDays: 30, - validUntil: '2024-12-31', + companyName: 'SSC Consolidation', + companyEmail: 'bookings@ssc.com', + originCFS: 'Le Havre', + origin: 'FRLEH', + portOfLoading: 'LE HAVRE', + routing: 'Direct', + destinationCFS: 'Shanghai', + destination: 'CNSHA', + destinationCountry: 'China', + containerType: 'LCL', + priceBreakdown: { + freightCharge: 440, + freightCurrency: 'USD', + fobFixed: 173, + fobHandling: 110, + fobDG: 0, + fobCurrency: 'EUR', + fobBreakdown: { + documentation: 55, + isps: 18, + handling: 110, + solas: 15, + customs: 85, + ams_aci: 0, + isf5: 0, + dgAdmin: 0, + }, + dgSurchargeAmount: null, + dgSurchargeCurrency: 'EUR', + dgSurchargeStatus: 'computed', + totalFreight: 440, + totalFob: 283, + totalPriceForSorting: 723, + primaryCurrency: 'USD', + }, + frequency: 'Weekly', + transitDays: 33, + validUntil: '2026-12-31', + dgAccepted: true, + dgSurchargeStatus: 'computed', + remarks: '', source: 'CSV', - matchScore: 95, + matchScore: 110, + serviceLevel: 'STANDARD', + priceMultiplier: 1.0, + originalTransitDays: 33, + adjustedTransitDays: 33, }, ], totalResults: 1, - searchedFiles: ['maersk-rates.csv'], - searchedAt: '2024-03-01T10:00:00Z', + searchedFiles: ['ssc-consolidation.csv'], + searchedAt: '2026-05-11T10:00:00Z', appliedFilters: {}, }; @@ -74,8 +108,8 @@ describe('useCsvRateSearch', () => { describe('search — success path', () => { it('sets loading=true while the request is in flight', async () => { - let resolveSearch: (v: CsvRateSearchResponse) => void; - mockSearchCsvRates.mockReturnValue( + let resolveSearch: (v: any) => void; + mockSearchCsvRatesWithOffers.mockReturnValue( new Promise(resolve => { resolveSearch = resolve; }) @@ -95,7 +129,7 @@ describe('useCsvRateSearch', () => { }); it('sets data and clears loading after a successful search', async () => { - mockSearchCsvRates.mockResolvedValue(mockResponse); + mockSearchCsvRatesWithOffers.mockResolvedValue(mockResponse as any); const { result } = renderHook(() => useCsvRateSearch()); @@ -108,8 +142,8 @@ describe('useCsvRateSearch', () => { expect(result.current.error).toBeNull(); }); - it('calls searchCsvRates with the given request', async () => { - mockSearchCsvRates.mockResolvedValue(mockResponse); + it('calls searchCsvRatesWithOffers with the given request', async () => { + mockSearchCsvRatesWithOffers.mockResolvedValue(mockResponse as any); const { result } = renderHook(() => useCsvRateSearch()); @@ -117,22 +151,20 @@ describe('useCsvRateSearch', () => { await result.current.search(mockRequest); }); - expect(mockSearchCsvRates).toHaveBeenCalledWith(mockRequest); + expect(mockSearchCsvRatesWithOffers).toHaveBeenCalledWith(mockRequest); }); it('clears a previous error when a new search starts', async () => { - mockSearchCsvRates.mockRejectedValueOnce(new Error('first error')); - mockSearchCsvRates.mockResolvedValueOnce(mockResponse); + mockSearchCsvRatesWithOffers.mockRejectedValueOnce(new Error('first error')); + mockSearchCsvRatesWithOffers.mockResolvedValueOnce(mockResponse as any); const { result } = renderHook(() => useCsvRateSearch()); - // First search fails await act(async () => { await result.current.search(mockRequest); }); expect(result.current.error).toBe('first error'); - // Second search succeeds — error must be cleared await act(async () => { await result.current.search(mockRequest); }); @@ -142,7 +174,7 @@ describe('useCsvRateSearch', () => { describe('search — error path', () => { it('sets error and clears data when the API throws', async () => { - mockSearchCsvRates.mockRejectedValue(new Error('Network error')); + mockSearchCsvRatesWithOffers.mockRejectedValue(new Error('Network error')); const { result } = renderHook(() => useCsvRateSearch()); @@ -156,7 +188,7 @@ describe('useCsvRateSearch', () => { }); it('uses a default error message when the error has no message', async () => { - mockSearchCsvRates.mockRejectedValue({}); + mockSearchCsvRatesWithOffers.mockRejectedValue({}); const { result } = renderHook(() => useCsvRateSearch()); @@ -164,13 +196,13 @@ describe('useCsvRateSearch', () => { await result.current.search(mockRequest); }); - expect(result.current.error).toBe('Failed to search rates'); + expect(result.current.error).toBe('Erreur lors de la recherche de tarifs'); }); }); describe('reset', () => { it('clears data, error, and loading', async () => { - mockSearchCsvRates.mockResolvedValue(mockResponse); + mockSearchCsvRatesWithOffers.mockResolvedValue(mockResponse as any); const { result } = renderHook(() => useCsvRateSearch()); diff --git a/apps/frontend/src/app/rates/csv-search/page.tsx b/apps/frontend/src/app/rates/csv-search/page.tsx index f8e0cdb..1f51876 100644 --- a/apps/frontend/src/app/rates/csv-search/page.tsx +++ b/apps/frontend/src/app/rates/csv-search/page.tsx @@ -1,12 +1,3 @@ -/** - * CSV Rate Search Page - * - * Complete rate search page with: - * - Volume/Weight/Pallet input - * - Advanced filters panel - * - Results table with CSV/API source badges - */ - 'use client'; import { useState } from 'react'; @@ -15,22 +6,21 @@ import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Switch } from '@/components/ui/switch'; import { Loader2, Search } from 'lucide-react'; -import { VolumeWeightInput } from '@/components/rate-search/VolumeWeightInput'; import { RateFiltersPanel } from '@/components/rate-search/RateFiltersPanel'; import { RateResultsTable } from '@/components/rate-search/RateResultsTable'; import { useCsvRateSearch } from '@/hooks/useCsvRateSearch'; import type { RateSearchFilters } from '@/types/rate-filters'; +import type { CsvRateSearchResult } from '@/types/rates'; export default function CsvRateSearchPage() { - // Search parameters - const [origin, setOrigin] = useState('NLRTM'); - const [destination, setDestination] = useState('USNYC'); - const [volumeCBM, setVolumeCBM] = useState(25.5); - const [weightKG, setWeightKG] = useState(3500); - const [palletCount, setPalletCount] = useState(10); + const [origin, setOrigin] = useState('FRFOS'); + const [destination, setDestination] = useState('CNSHA'); + const [volumeCBM, setVolumeCBM] = useState(10); + const [weightKG, setWeightKG] = useState(2500); + const [hasDangerousGoods, setHasDangerousGoods] = useState(false); const [filters, setFilters] = useState({}); - const [currency, setCurrency] = useState<'USD' | 'EUR'>('USD'); const { data, loading, error, search } = useCsvRateSearch(); @@ -40,54 +30,49 @@ export default function CsvRateSearchPage() { destination, volumeCBM, weightKG, - palletCount, containerType: 'LCL', + hasDangerousGoods, filters, }); }; - const handleResetFilters = () => { - setFilters({}); - }; - - const handleBooking = (result: any) => { - alert(`Booking pour ${result.companyName}: ${result.origin} → ${result.destination}`); - // TODO: Implement actual booking flow + const handleBooking = (result: CsvRateSearchResult) => { + alert( + `Réservation pour ${result.companyName}\nContact : ${result.companyEmail}\nRoute : ${result.originCFS} → ${result.destinationCFS}` + ); }; return (
- {/* Page Header */}
-

Recherche de tarifs CSV

+

Comparateur de tarifs LCL

- Recherchez des tarifs de transport maritime avec filtres avancés + Comparez les tarifs de fret maritime LCL multi-fournisseurs avec détail FOB et fret

- {/* Left Column: Filters */} + {/* Filtres */}
setFilters({})} />
- {/* Right Column: Search Form + Results */} + {/* Formulaire + résultats */}
- {/* Search Form */} Paramètres de recherche - Indiquez votre trajet et les dimensions de votre envoi + Indiquez votre trajet, volume et poids pour obtenir des tarifs comparés - {/* Origin and Destination */} + {/* Origine / Destination */}
-
- {/* Volume, Weight, Pallets */} - - - {/* Currency Selection */} -
- -
- - + {/* Volume / Poids */} +
+
+ + setVolumeCBM(parseFloat(e.target.value) || 0)} + /> +
+
+ + setWeightKG(parseInt(e.target.value, 10) || 0)} + />
- {/* Search Button */} + {/* Marchandises dangereuses */} +
+ + +
+ + {/* Recherche */} - {/* Error Alert */} {error && ( {error} )} - {/* Search Info */} {data && ( - Recherche effectuée le {new Date(data.searchedAt).toLocaleString('fr-FR')} •{' '} - {data.searchedFiles.length} fichier(s) CSV analysé(s) • {data.totalResults} tarif(s) - trouvé(s) + {new Date(data.searchedAt).toLocaleString('fr-FR')} — {data.searchedFiles.length}{' '} + fournisseur(s) analysé(s) — {data.totalResults} tarif(s) trouvé(s) )} - {/* Results Table */} {data && data.results.length > 0 && ( - Résultats de recherche + Résultats de comparaison - {data.totalResults} tarif{data.totalResults > 1 ? 's' : ''} correspondant à vos - critères + {data.totalResults} tarif{data.totalResults > 1 ? 's' : ''} — prix = Fret + + Frais FOB (deux devises possibles) - + )} - {/* No Results */} {data && data.results.length === 0 && ( -

Aucun tarif trouvé pour cette recherche.

+

Aucun tarif trouvé pour cette route.

- Essayez d'ajuster vos critères de recherche ou vos filtres. + Vérifiez les codes UN/LOCODE saisis (ex: FRFOS, CNSHA).

diff --git a/apps/frontend/src/components/rate-search/RateFiltersPanel.tsx b/apps/frontend/src/components/rate-search/RateFiltersPanel.tsx index 5673d20..478a38c 100644 --- a/apps/frontend/src/components/rate-search/RateFiltersPanel.tsx +++ b/apps/frontend/src/components/rate-search/RateFiltersPanel.tsx @@ -1,13 +1,5 @@ -/** - * Rate Filters Panel Component - * - * Advanced filters panel for rate search - * Includes all filter options: companies, volume, weight, price, transit, etc. - */ - 'use client'; -import { useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; @@ -39,186 +31,96 @@ export function RateFiltersPanel({ }: RateFiltersPanelProps) { const { companies, containerTypes, loading } = useFilterOptions(); - const updateFilter = (key: K, value: RateSearchFilters[K]) => { - onFiltersChange({ - ...filters, - [key]: value, - }); - }; - - const handleReset = () => { - onReset(); + const update = (key: K, value: RateSearchFilters[K]) => { + onFiltersChange({ ...filters, [key]: value }); }; return ( Filtres - - {/* Compagnies */} + {/* Fournisseurs */}
- + updateFilter('companies', selected)} + onChange={selected => update('companies', selected)} disabled={loading} />
- {/* Volume CBM */} -
- -
-
- - updateFilter('minVolumeCBM', parseFloat(e.target.value) || undefined) - } - /> -
-
- - updateFilter('maxVolumeCBM', parseFloat(e.target.value) || undefined) - } - /> -
-
-
- - {/* Poids (kg) */} -
- -
-
- - updateFilter('minWeightKG', parseInt(e.target.value, 10) || undefined) - } - /> -
-
- - updateFilter('maxWeightKG', parseInt(e.target.value, 10) || undefined) - } - /> -
-
-
- - {/* Palettes */} -
- - updateFilter('palletCount', parseInt(e.target.value, 10) || undefined)} + {/* Routing direct uniquement */} +
+ update('onlyDirect', checked)} /> -

Laisser vide pour ignorer

+
- {/* Prix */} + {/* Exclure les routes sans DG */} +
+ update('excludeNonDgRoutes', checked)} + /> + +
+ + {/* Prix (total estimé) */}
- +
-
- updateFilter('minPrice', parseFloat(e.target.value) || undefined)} - /> -
-
- updateFilter('maxPrice', parseFloat(e.target.value) || undefined)} - /> -
+ update('minPrice', parseFloat(e.target.value) || undefined)} + /> + update('maxPrice', parseFloat(e.target.value) || undefined)} + />
- {/* Devise */} -
- - -
- {/* Durée de transit */}
- +
-
- - updateFilter('minTransitDays', parseInt(e.target.value, 10) || undefined) - } - /> -
-
- - updateFilter('maxTransitDays', parseInt(e.target.value, 10) || undefined) - } - /> -
+ update('minTransitDays', parseInt(e.target.value, 10) || undefined)} + /> + update('maxTransitDays', parseInt(e.target.value, 10) || undefined)} + />
@@ -228,7 +130,7 @@ export function RateFiltersPanel({
- {/* Prix all-in uniquement */} -
- updateFilter('onlyAllInPrices', checked)} - /> - -
- - {/* Date de départ */} + {/* Date de validité */}
updateFilter('departureDate', e.target.value || undefined)} + onChange={e => update('departureDate', e.target.value || undefined)} />

- Filtrer par validité des tarifs à cette date + Filtre les tarifs valides à cette date

{/* Résultats */}
- Résultats trouvés + Résultats {resultsCount}
diff --git a/apps/frontend/src/components/rate-search/RateResultsTable.tsx b/apps/frontend/src/components/rate-search/RateResultsTable.tsx index 86b81f2..f5245de 100644 --- a/apps/frontend/src/components/rate-search/RateResultsTable.tsx +++ b/apps/frontend/src/components/rate-search/RateResultsTable.tsx @@ -1,10 +1,3 @@ -/** - * Rate Results Table Component - * - * Displays search results in a table format - * Shows CSV/API source, prices, transit time, and surcharge details - */ - 'use client'; import { useState } from 'react'; @@ -26,19 +19,31 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; -import { ArrowUpDown, Info } from 'lucide-react'; -import type { CsvRateResult } from '@/types/rate-filters'; +import { ArrowUpDown, Info, Mail, AlertTriangle } from 'lucide-react'; +import type { CsvRateSearchResult, ServiceLevel } from '@/types/rates'; interface RateResultsTableProps { - results: CsvRateResult[]; - currency?: 'USD' | 'EUR'; - onBooking?: (result: CsvRateResult) => void; + results: CsvRateSearchResult[]; + onBooking?: (result: CsvRateSearchResult) => void; } type SortField = 'price' | 'transit' | 'company' | 'matchScore'; type SortOrder = 'asc' | 'desc'; -export function RateResultsTable({ results, currency = 'USD', onBooking }: RateResultsTableProps) { +const SERVICE_LEVEL_LABELS: Record = { + 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 = { + 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('price'); const [sortOrder, setSortOrder] = useState('asc'); @@ -52,45 +57,33 @@ export function RateResultsTable({ results, currency = 'USD', onBooking }: RateR }; const sortedResults = [...results].sort((a, b) => { - let aValue: number | string; - let bValue: number | string; + let aVal: number | string; + let bVal: number | string; switch (sortField) { case 'price': - aValue = currency === 'USD' ? a.priceUSD : a.priceEUR; - bValue = currency === 'USD' ? b.priceUSD : b.priceEUR; + aVal = a.priceBreakdown.totalPriceForSorting; + bVal = b.priceBreakdown.totalPriceForSorting; break; case 'transit': - aValue = a.transitDays; - bValue = b.transitDays; + aVal = a.transitDays; + bVal = b.transitDays; break; case 'company': - aValue = a.companyName; - bValue = b.companyName; + aVal = a.companyName; + bVal = b.companyName; break; case 'matchScore': - aValue = a.matchScore; - bValue = b.matchScore; + aVal = a.matchScore; + bVal = b.matchScore; break; default: return 0; } - if (sortOrder === 'asc') { - return aValue > bValue ? 1 : -1; - } else { - return aValue < bValue ? 1 : -1; - } + return sortOrder === 'asc' ? (aVal > bVal ? 1 : -1) : aVal < bVal ? 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 }) => ( + + + + Détail du prix estimé + + {result.companyName} — {result.originCFS || result.origin} →{' '} + {result.destinationCFS || result.destination} + + +
+ {/* Fret */} +
+

+ Fret ({bd.freightCurrency}) +

+
+ Taux de fret + + {bd.freightCurrency} {bd.freightCharge.toFixed(2)} + +
+
+ + {/* FOB */} +
+

+ Frais FOB ({bd.fobCurrency}) +

+
+ {bd.fobBreakdown.documentation > 0 && ( +
+ Documentation + {bd.fobBreakdown.documentation} +
+ )} + {bd.fobBreakdown.isps > 0 && ( +
+ ISPS + {bd.fobBreakdown.isps} +
+ )} + {bd.fobBreakdown.handling > 0 && ( +
+ Manutention + {bd.fobBreakdown.handling.toFixed(2)} +
+ )} + {bd.fobBreakdown.solas > 0 && ( +
+ SOLAS + {bd.fobBreakdown.solas} +
+ )} + {bd.fobBreakdown.customs > 0 && ( +
+ Douane export + {bd.fobBreakdown.customs} +
+ )} + {bd.fobBreakdown.ams_aci > 0 && ( +
+ AMS/ACI + {bd.fobBreakdown.ams_aci} +
+ )} + {bd.fobBreakdown.isf5 > 0 && ( +
+ ISF5 + {bd.fobBreakdown.isf5} +
+ )} + {bd.fobBreakdown.dgAdmin > 0 && ( +
+ Admin DG + {bd.fobBreakdown.dgAdmin} +
+ )} +
+
+ + {/* DG surcharge if applicable */} + {bd.dgSurchargeStatus === 'on_request' && ( +
+ Surcharge DG : sur demande — contactez le fournisseur +
+ )} + {bd.dgSurchargeStatus === 'not_accepted' && ( +
+ DG non accepté sur cette route +
+ )} + {bd.dgSurchargeAmount !== null && bd.dgSurchargeAmount > 0 && ( +
+ + Surcharge DG ({bd.dgSurchargeCurrency}) + + {bd.dgSurchargeAmount.toFixed(2)} +
+ )} + + {/* Totals */} +
+
+ Total Fret + + {bd.freightCurrency} {bd.totalFreight.toFixed(2)} + +
+
+ Total FOB + + {bd.fobCurrency} {bd.totalFob.toFixed(2)} + +
+ {bd.freightCurrency !== bd.fobCurrency && ( +

+ ⚠ Le fret est en {bd.freightCurrency} et le FOB en {bd.fobCurrency}. Un taux de + change est nécessaire pour le total exact. +

+ )} +
+ + {/* Contact */} +
+

+ Contact booking :{' '} + + {result.companyEmail} + +

+ {result.remarks && ( +

Ref: {result.remarks}

+ )} +
+
+
+ + ); +} diff --git a/apps/frontend/src/hooks/useCsvRateSearch.ts b/apps/frontend/src/hooks/useCsvRateSearch.ts index 21d7ed8..ead6a12 100644 --- a/apps/frontend/src/hooks/useCsvRateSearch.ts +++ b/apps/frontend/src/hooks/useCsvRateSearch.ts @@ -1,12 +1,13 @@ /** * CSV Rate Search Hook * - * React hook for searching CSV-based rates with filters + * React hook for searching CSV-based rates with service level offers + * (ECONOMIC / STANDARD / RAPID). */ import { useState } from 'react'; -import { searchCsvRates } from '@/lib/api/csv-rates'; -import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rate-filters'; +import { searchCsvRatesWithOffers } from '@/lib/api/rates'; +import type { CsvRateSearchRequest, CsvRateSearchResponse } from '@/types/rates'; interface UseCsvRateSearchResult { data: CsvRateSearchResponse | null; @@ -26,10 +27,10 @@ export function useCsvRateSearch(): UseCsvRateSearchResult { setError(null); try { - const response = await searchCsvRates(request); - setData(response); + const response = await searchCsvRatesWithOffers(request); + setData(response as unknown as CsvRateSearchResponse); } catch (err: any) { - setError(err?.message || 'Failed to search rates'); + setError(err?.message || 'Erreur lors de la recherche de tarifs'); setData(null); } finally { setLoading(false); @@ -42,11 +43,5 @@ export function useCsvRateSearch(): UseCsvRateSearchResult { setLoading(false); }; - return { - data, - loading, - error, - search, - reset, - }; + return { data, loading, error, search, reset }; } diff --git a/apps/frontend/src/types/api.ts b/apps/frontend/src/types/api.ts index b9ddcef..9fd3b94 100644 --- a/apps/frontend/src/types/api.ts +++ b/apps/frontend/src/types/api.ts @@ -457,73 +457,86 @@ export interface CsvRateSearchRequest { destination: string; volumeCBM: number; weightKG: number; - palletCount?: number; containerType?: string; hasDangerousGoods?: boolean; - requiresSpecialHandling?: boolean; - requiresTailgate?: boolean; - requiresStraps?: boolean; - requiresThermalCover?: boolean; - hasRegulatedProducts?: boolean; - requiresAppointment?: boolean; filters?: RateSearchFilters; } export interface RateSearchFilters { companies?: string[]; - minVolumeCBM?: number; - maxVolumeCBM?: number; - minWeightKG?: number; - maxWeightKG?: number; - palletCount?: number; + onlyDirect?: boolean; + excludeNonDgRoutes?: boolean; minPrice?: number; maxPrice?: number; currency?: 'USD' | 'EUR'; minTransitDays?: number; maxTransitDays?: number; containerTypes?: string[]; - onlyAllInPrices?: boolean; - departureDate?: Date; + departureDate?: string; + serviceLevels?: ('RAPID' | 'STANDARD' | 'ECONOMIC')[]; } -export interface SurchargeItem { - code: string; - description: string; - amount: number; - type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE'; +export type DgSurchargeStatus = 'computed' | 'on_request' | 'not_accepted'; + +export interface FobBreakdown { + documentation: number; + isps: number; + handling: number; + solas: number; + customs: number; + ams_aci: number; + isf5: number; + dgAdmin: number; } export interface PriceBreakdown { - basePrice: number; - volumeCharge: number; - weightCharge: number; - palletCharge: number; - surcharges: SurchargeItem[]; - totalSurcharges: number; - totalPrice: number; - currency: string; + freightCharge: number; + freightCurrency: string; + fobFixed: number; + fobHandling: number; + fobDG: number; + fobCurrency: string; + fobBreakdown: FobBreakdown; + dgSurchargeAmount: number | null; + dgSurchargeCurrency: string; + dgSurchargeStatus: DgSurchargeStatus; + totalFreight: number; + totalFob: number; + totalPriceForSorting: number; + primaryCurrency: string; } -export interface CsvRateResult { +export interface CsvRateSearchResult { companyName: string; companyEmail: string; + originCFS: string; origin: string; + portOfLoading: string; + routing: string; + destinationCFS: string; destination: string; + destinationCountry: string; containerType: string; - priceUSD: number; - priceEUR: number; - primaryCurrency: string; priceBreakdown: PriceBreakdown; - hasSurcharges: boolean; - surchargeDetails: string | null; + frequency: string; transitDays: number; validUntil: string; - source: 'CSV' | 'API'; + dgAccepted: boolean; + dgSurchargeStatus: DgSurchargeStatus; + remarks: string; + source: string; matchScore: number; + serviceLevel?: 'RAPID' | 'STANDARD' | 'ECONOMIC'; + priceMultiplier?: number; + originalTransitDays?: number; + adjustedTransitDays?: number; } +/** @deprecated Use CsvRateSearchResult */ +export type CsvRateResult = CsvRateSearchResult; + export interface CsvRateSearchResponse { - results: CsvRateResult[]; + results: CsvRateSearchResult[]; totalResults: number; searchedFiles: string[]; searchedAt: string; diff --git a/apps/frontend/src/types/rate-filters.ts b/apps/frontend/src/types/rate-filters.ts index 472c7a3..38ce288 100644 --- a/apps/frontend/src/types/rate-filters.ts +++ b/apps/frontend/src/types/rate-filters.ts @@ -1,74 +1,18 @@ -/** - * Rate Search Filters Types - * - * TypeScript types for advanced rate search filters - * Matches backend DTOs - */ +import type { CsvRateSearchResult, CsvRateSearchResponse, RateSearchFilters } from './rates'; -export interface RateSearchFilters { - // 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 -} +// Re-export unified types +export type { RateSearchFilters, CsvRateSearchResult as CsvRateResult, CsvRateSearchResponse }; export interface CsvRateSearchRequest { origin: string; destination: string; volumeCBM: number; weightKG: number; - palletCount?: number; containerType?: string; + hasDangerousGoods?: boolean; 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 { companies: string[]; total: number; diff --git a/apps/frontend/src/types/rates.ts b/apps/frontend/src/types/rates.ts index 46e24d3..d368763 100644 --- a/apps/frontend/src/types/rates.ts +++ b/apps/frontend/src/types/rates.ts @@ -1,92 +1,95 @@ -/** - * Rate Search Types - * - * TypeScript types for rate search functionality - */ +export type ServiceLevel = 'RAPID' | 'STANDARD' | 'ECONOMIC'; +export type DgSurchargeStatus = 'computed' | 'on_request' | 'not_accepted'; + +export interface FobBreakdown { + documentation: number; + isps: number; + handling: number; + solas: number; + customs: number; + ams_aci: number; + isf5: number; + dgAdmin: number; +} + +export interface PriceBreakdown { + // Freight (in freightCurrency) + freightCharge: number; + freightCurrency: string; + + // FOB (in fobCurrency) + fobFixed: number; + fobHandling: number; + fobDG: number; + fobCurrency: string; + fobBreakdown: FobBreakdown; + + // DG surcharge + dgSurchargeAmount: number | null; + dgSurchargeCurrency: string; + dgSurchargeStatus: DgSurchargeStatus; + + // Totals + totalFreight: number; + totalFob: number; + totalPriceForSorting: number; // naive sum for sorting + primaryCurrency: string; +} -/** - * CSV Rate Search Request - */ export interface CsvRateSearchRequest { origin: string; destination: string; volumeCBM: number; weightKG: number; - palletCount?: number; containerType?: string; hasDangerousGoods?: boolean; - requiresSpecialHandling?: boolean; - requiresTailgate?: boolean; - requiresStraps?: boolean; - requiresThermalCover?: boolean; - hasRegulatedProducts?: boolean; - requiresAppointment?: boolean; + filters?: RateSearchFilters; } -/** - * Surcharge Details - */ -export interface Surcharge { - code: string; - description: string; - amount: number; - type: string; +export interface RateSearchFilters { + companies?: string[]; + onlyDirect?: boolean; + excludeNonDgRoutes?: boolean; + minPrice?: number; + maxPrice?: number; + currency?: 'USD' | 'EUR'; + minTransitDays?: number; + maxTransitDays?: number; + containerTypes?: string[]; + departureDate?: string; + serviceLevels?: ServiceLevel[]; } -/** - * Price Breakdown - */ -export interface PriceBreakdown { - basePrice: number; - volumeCharge: number; - weightCharge: number; - palletCharge: number; - surcharges: Surcharge[]; - totalSurcharges: number; - totalPrice: number; - currency: string; -} - -/** - * Service Level for Rate Offers - */ -export type ServiceLevel = 'RAPID' | 'STANDARD' | 'ECONOMIC'; - -/** - * CSV Rate Search Result - */ export interface CsvRateSearchResult { companyName: string; companyEmail: string; + originCFS: string; origin: string; + portOfLoading: string; + routing: string; + destinationCFS: string; destination: string; + destinationCountry: string; containerType: string; - priceUSD: number; - priceEUR: number; - primaryCurrency: string; priceBreakdown: PriceBreakdown; - hasSurcharges: boolean; - surchargeDetails: string | null; + frequency: string; transitDays: number; validUntil: string; + dgAccepted: boolean; + dgSurchargeStatus: DgSurchargeStatus; + remarks: string; source: string; matchScore: number; - // Service level offer fields (only present when using search-csv-offers endpoint) serviceLevel?: ServiceLevel; - originalPrice?: { - usd: number; - eur: number; - }; + priceMultiplier?: number; originalTransitDays?: number; + adjustedTransitDays?: number; } -/** - * CSV Rate Search Response - */ export interface CsvRateSearchResponse { results: CsvRateSearchResult[]; totalResults: number; searchedFiles: string[]; - searchedAt: Date; + searchedAt: string; appliedFilters: Record; } diff --git a/docker-compose.full.yml b/docker-compose.full.yml index e32f650..9da34d4 100644 --- a/docker-compose.full.yml +++ b/docker-compose.full.yml @@ -82,6 +82,8 @@ services: condition: service_healthy redis: condition: service_healthy + env_file: + - ./apps/backend/.env environment: NODE_ENV: development LOG_FORMAT: json