xpeditis2.0/apps/backend/src/domain/services/rate-search.service.ts
2025-10-27 20:54:01 +01:00

166 lines
5.0 KiB
TypeScript

/**
* RateSearchService
*
* Domain service implementing the rate search business logic
*
* Business Rules:
* - Query multiple carriers in parallel
* - Cache results for 15 minutes
* - Handle carrier timeouts gracefully (5s max)
* - Return results even if some carriers fail
*/
import { RateQuote } from '../entities/rate-quote.entity';
import { SearchRatesPort, RateSearchInput, RateSearchOutput } from '../ports/in/search-rates.port';
import { CarrierConnectorPort } from '../ports/out/carrier-connector.port';
import { CachePort } from '../ports/out/cache.port';
import { RateQuoteRepository } from '../ports/out/rate-quote.repository';
import { PortRepository } from '../ports/out/port.repository';
import { CarrierRepository } from '../ports/out/carrier.repository';
import { PortNotFoundException } from '../exceptions/port-not-found.exception';
import { v4 as uuidv4 } from 'uuid';
export class RateSearchService implements SearchRatesPort {
private static readonly CACHE_TTL_SECONDS = 15 * 60; // 15 minutes
constructor(
private readonly carrierConnectors: CarrierConnectorPort[],
private readonly cache: CachePort,
private readonly rateQuoteRepository: RateQuoteRepository,
private readonly portRepository: PortRepository,
private readonly carrierRepository: CarrierRepository
) {}
async execute(input: RateSearchInput): Promise<RateSearchOutput> {
const searchId = uuidv4();
const searchedAt = new Date();
// Validate ports exist
await this.validatePorts(input.origin, input.destination);
// Generate cache key
const cacheKey = this.generateCacheKey(input);
// Check cache first
const cachedResults = await this.cache.get<RateSearchOutput>(cacheKey);
if (cachedResults) {
return cachedResults;
}
// Filter carriers if preferences specified
const connectorsToQuery = this.filterCarrierConnectors(input.carrierPreferences);
// Query all carriers in parallel with Promise.allSettled
const carrierResults = await Promise.allSettled(
connectorsToQuery.map(connector => this.queryCarrier(connector, input))
);
// Process results
const quotes: RateQuote[] = [];
const carrierResultsSummary: RateSearchOutput['carrierResults'] = [];
for (let i = 0; i < carrierResults.length; i++) {
const result = carrierResults[i];
const connector = connectorsToQuery[i];
const carrierName = connector.getCarrierName();
if (result.status === 'fulfilled') {
const carrierQuotes = result.value;
quotes.push(...carrierQuotes);
carrierResultsSummary.push({
carrierName,
status: 'success',
resultCount: carrierQuotes.length,
});
} else {
// Handle error
const error = result.reason;
carrierResultsSummary.push({
carrierName,
status: error.name === 'CarrierTimeoutException' ? 'timeout' : 'error',
resultCount: 0,
errorMessage: error.message,
});
}
}
// Save rate quotes to database
if (quotes.length > 0) {
await this.rateQuoteRepository.saveMany(quotes);
}
// Build output
const output: RateSearchOutput = {
quotes,
searchId,
searchedAt,
totalResults: quotes.length,
carrierResults: carrierResultsSummary,
};
// Cache results
await this.cache.set(cacheKey, output, RateSearchService.CACHE_TTL_SECONDS);
return output;
}
private async validatePorts(originCode: string, destinationCode: string): Promise<void> {
const [origin, destination] = await Promise.all([
this.portRepository.findByCode(originCode),
this.portRepository.findByCode(destinationCode),
]);
if (!origin) {
throw new PortNotFoundException(originCode);
}
if (!destination) {
throw new PortNotFoundException(destinationCode);
}
}
private generateCacheKey(input: RateSearchInput): string {
const parts = [
'rate-search',
input.origin,
input.destination,
input.containerType,
input.mode,
input.departureDate.toISOString().split('T')[0],
input.quantity || 1,
input.isHazmat ? 'hazmat' : 'standard',
];
return parts.join(':');
}
private filterCarrierConnectors(carrierPreferences?: string[]): CarrierConnectorPort[] {
if (!carrierPreferences || carrierPreferences.length === 0) {
return this.carrierConnectors;
}
return this.carrierConnectors.filter(connector =>
carrierPreferences.includes(connector.getCarrierCode())
);
}
private async queryCarrier(
connector: CarrierConnectorPort,
input: RateSearchInput
): Promise<RateQuote[]> {
return connector.searchRates({
origin: input.origin,
destination: input.destination,
containerType: input.containerType,
mode: input.mode,
departureDate: input.departureDate,
quantity: input.quantity,
weight: input.weight,
volume: input.volume,
isHazmat: input.isHazmat,
imoClass: input.imoClass,
});
}
}