xpeditis2.0/apps/backend/src/domain/services/csv-rate-search.service.ts
David 890bc189ee
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
fix v0.2
2025-11-12 18:00:33 +01:00

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));
}
}