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 { Money } from '../value-objects/money.vo'; import { SearchCsvRatesPort, CsvRateSearchInput, CsvRateSearchOutput, CsvRateSearchResult, RateSearchFilters, } from '../ports/in/search-csv-rates.port'; import { CsvRateLoaderPort } from '../ports/out/csv-rate-loader.port'; import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service'; /** * Config Metadata Interface (to avoid circular dependency) */ interface CsvRateConfig { companyName: string; csvFilePath: string; metadata?: { companyEmail?: string; [key: string]: any; }; } /** * 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; constructor( private readonly csvRateLoader: CsvRateLoaderPort, private readonly configRepository?: CsvRateConfigRepositoryPort ) { this.priceCalculator = new CsvRatePriceCalculatorService(); } 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); } // 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), }; }); // Sort by total price (ascending) results.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice); return { results, totalResults: results.length, searchedFiles: await this.csvRateLoader.getAvailableCsvFiles(), searchedAt: searchStartTime, appliedFilters: input.filters || {}, }; } async getAvailableCompanies(): Promise { const allRates = await this.loadAllRates(); const companies = new Set(allRates.map(rate => rate.companyName)); return Array.from(companies).sort(); } async getAvailableContainerTypes(): Promise { const allRates = await this.loadAllRates(); const types = new Set(allRates.map(rate => rate.containerType.getValue())); return Array.from(types).sort(); } /** * Load all rates from all CSV files */ private async loadAllRates(): Promise { // If config repository is available, load rates with emails from configs if (this.configRepository) { const configs = await this.configRepository.findActiveConfigs(); const ratePromises = configs.map(config => { const email = config.metadata?.companyEmail || 'bookings@example.com'; return this.csvRateLoader.loadRatesFromCsv(config.csvFilePath, email); }); const rateArrays = await Promise.all(ratePromises); return rateArrays.flat(); } // 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 rateArrays = await Promise.all(ratePromises); return rateArrays.flat(); } /** * 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 ): CsvRate[] { let filtered = rates; // Company filter if (filters.companies && filters.companies.length > 0) { 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!); } // 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!); } // 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!); } if (filters.maxTransitDays !== undefined) { filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!); } // Container type filter if (filters.containerTypes && filters.containerTypes.length > 0) { 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!)); } return filtered; } /** * Calculate match score (0-100) based on how well rate matches input * Higher score = better match */ private calculateMatchScore(rate: CsvRate, input: CsvRateSearchInput): 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) { score -= 5; } // Increase score for all-in prices (simpler for customers) if (rate.isAllInPrice()) { score += 5; } // Reduce score for rates expiring soon const daysUntilExpiry = Math.floor( (rate.validity.getEndDate().getTime() - Date.now()) / (1000 * 60 * 60 * 24) ); if (daysUntilExpiry < 7) { score -= 10; } else if (daysUntilExpiry < 30) { score -= 5; } return Math.max(0, Math.min(100, score)); } }