Some checks failed
CI/CD Pipeline - Xpeditis PreProd / Frontend - Build & Test (push) Failing after 5m31s
CI/CD Pipeline - Xpeditis PreProd / Frontend - Docker Build & Push (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Backend - Build & Test (push) Failing after 5m42s
CI/CD Pipeline - Xpeditis PreProd / Backend - Docker Build & Push (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Deploy to PreProd Server (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Run Smoke Tests (push) Has been skipped
298 lines
9.7 KiB
TypeScript
298 lines
9.7 KiB
TypeScript
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<CsvRateConfig[]>;
|
|
}
|
|
|
|
/**
|
|
* CSV Rate Search Service
|
|
*
|
|
* Domain service implementing CSV rate search use case.
|
|
* Applies business rules for matching rates and filtering.
|
|
*
|
|
* Pure domain logic - no framework dependencies.
|
|
*/
|
|
export class CsvRateSearchService implements SearchCsvRatesPort {
|
|
private readonly priceCalculator: CsvRatePriceCalculatorService;
|
|
|
|
constructor(
|
|
private readonly csvRateLoader: CsvRateLoaderPort,
|
|
private readonly configRepository?: CsvRateConfigRepositoryPort
|
|
) {
|
|
this.priceCalculator = new CsvRatePriceCalculatorService();
|
|
}
|
|
|
|
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
|
|
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<string[]> {
|
|
const allRates = await this.loadAllRates();
|
|
const companies = new Set(allRates.map(rate => rate.companyName));
|
|
return Array.from(companies).sort();
|
|
}
|
|
|
|
async getAvailableContainerTypes(): Promise<string[]> {
|
|
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<CsvRate[]> {
|
|
// 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));
|
|
}
|
|
}
|