166 lines
5.0 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|