520 lines
16 KiB
TypeScript
520 lines
16 KiB
TypeScript
import {
|
|
Controller,
|
|
Post,
|
|
Get,
|
|
Body,
|
|
Query,
|
|
HttpCode,
|
|
HttpStatus,
|
|
Logger,
|
|
UsePipes,
|
|
ValidationPipe,
|
|
UseGuards,
|
|
Inject,
|
|
} from '@nestjs/common';
|
|
import {
|
|
ApiTags,
|
|
ApiOperation,
|
|
ApiResponse,
|
|
ApiBadRequestResponse,
|
|
ApiInternalServerErrorResponse,
|
|
ApiBearerAuth,
|
|
ApiQuery,
|
|
} from '@nestjs/swagger';
|
|
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
|
|
import { RateQuoteMapper } from '../mappers';
|
|
import { RateSearchService } from '@domain/services/rate-search.service';
|
|
import { CsvRateSearchService } from '@domain/services/csv-rate-search.service';
|
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
|
import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
|
|
import {
|
|
AvailableCompaniesDto,
|
|
FilterOptionsDto,
|
|
AvailableOriginsDto,
|
|
AvailableDestinationsDto,
|
|
RoutePortInfoDto,
|
|
} from '../dto/csv-rate-upload.dto';
|
|
import { CsvRateMapper } from '../mappers/csv-rate.mapper';
|
|
import { PortRepository, PORT_REPOSITORY } from '@domain/ports/out/port.repository';
|
|
|
|
@ApiTags('Rates')
|
|
@Controller('rates')
|
|
@ApiBearerAuth()
|
|
export class RatesController {
|
|
private readonly logger = new Logger(RatesController.name);
|
|
|
|
constructor(
|
|
private readonly rateSearchService: RateSearchService,
|
|
private readonly csvRateSearchService: CsvRateSearchService,
|
|
private readonly csvRateMapper: CsvRateMapper,
|
|
@Inject(PORT_REPOSITORY) private readonly portRepository: PortRepository
|
|
) {}
|
|
|
|
@Post('search')
|
|
@UseGuards(JwtAuthGuard)
|
|
@HttpCode(HttpStatus.OK)
|
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
|
@ApiOperation({
|
|
summary: 'Search shipping rates',
|
|
description:
|
|
'Search for available shipping rates from multiple carriers. Results are cached for 15 minutes. Requires authentication.',
|
|
})
|
|
@ApiResponse({
|
|
status: HttpStatus.OK,
|
|
description: 'Rate search completed successfully',
|
|
type: RateSearchResponseDto,
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Unauthorized - missing or invalid token',
|
|
})
|
|
@ApiBadRequestResponse({
|
|
description: 'Invalid request parameters',
|
|
schema: {
|
|
example: {
|
|
statusCode: 400,
|
|
message: ['Origin must be a valid 5-character UN/LOCODE (e.g., NLRTM)'],
|
|
error: 'Bad Request',
|
|
},
|
|
},
|
|
})
|
|
@ApiInternalServerErrorResponse({
|
|
description: 'Internal server error',
|
|
})
|
|
async searchRates(
|
|
@Body() dto: RateSearchRequestDto,
|
|
@CurrentUser() user: UserPayload
|
|
): Promise<RateSearchResponseDto> {
|
|
const startTime = Date.now();
|
|
this.logger.log(
|
|
`[User: ${user.email}] Searching rates: ${dto.origin} → ${dto.destination}, ${dto.containerType}`
|
|
);
|
|
|
|
try {
|
|
// Convert DTO to domain input
|
|
const searchInput = {
|
|
origin: dto.origin,
|
|
destination: dto.destination,
|
|
containerType: dto.containerType,
|
|
mode: dto.mode,
|
|
departureDate: new Date(dto.departureDate),
|
|
quantity: dto.quantity,
|
|
weight: dto.weight,
|
|
volume: dto.volume,
|
|
isHazmat: dto.isHazmat,
|
|
imoClass: dto.imoClass,
|
|
};
|
|
|
|
// Execute search
|
|
const result = await this.rateSearchService.execute(searchInput);
|
|
|
|
// Convert domain entities to DTOs
|
|
const quoteDtos = RateQuoteMapper.toDtoArray(result.quotes);
|
|
|
|
const responseTimeMs = Date.now() - startTime;
|
|
this.logger.log(`Rate search completed: ${quoteDtos.length} quotes, ${responseTimeMs}ms`);
|
|
|
|
return {
|
|
quotes: quoteDtos,
|
|
count: quoteDtos.length,
|
|
origin: dto.origin,
|
|
destination: dto.destination,
|
|
departureDate: dto.departureDate,
|
|
containerType: dto.containerType,
|
|
mode: dto.mode,
|
|
fromCache: false, // TODO: Implement cache detection
|
|
responseTimeMs,
|
|
};
|
|
} catch (error: any) {
|
|
this.logger.error(`Rate search failed: ${error?.message || 'Unknown error'}`, error?.stack);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search CSV-based rates with advanced filters
|
|
*/
|
|
@Post('search-csv')
|
|
@UseGuards(JwtAuthGuard)
|
|
@HttpCode(HttpStatus.OK)
|
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
|
@ApiOperation({
|
|
summary: 'Search CSV-based rates with advanced filters',
|
|
description:
|
|
'Search for rates from CSV-loaded carriers (SSC, ECU, TCC, NVO) with advanced filtering options including volume, weight, pallets, price range, transit time, and more.',
|
|
})
|
|
@ApiResponse({
|
|
status: HttpStatus.OK,
|
|
description: 'CSV rate search completed successfully',
|
|
type: CsvRateSearchResponseDto,
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Unauthorized - missing or invalid token',
|
|
})
|
|
@ApiBadRequestResponse({
|
|
description: 'Invalid request parameters',
|
|
})
|
|
async searchCsvRates(
|
|
@Body() dto: CsvRateSearchDto,
|
|
@CurrentUser() user: UserPayload
|
|
): Promise<CsvRateSearchResponseDto> {
|
|
const startTime = Date.now();
|
|
this.logger.log(
|
|
`[User: ${user.email}] Searching CSV rates: ${dto.origin} → ${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg`
|
|
);
|
|
|
|
try {
|
|
// Map DTO to domain input
|
|
const searchInput = {
|
|
origin: dto.origin,
|
|
destination: dto.destination,
|
|
volumeCBM: dto.volumeCBM,
|
|
weightKG: dto.weightKG,
|
|
palletCount: dto.palletCount ?? 0,
|
|
containerType: dto.containerType,
|
|
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
|
|
|
// Service requirements for detailed pricing
|
|
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
|
requiresSpecialHandling: dto.requiresSpecialHandling ?? false,
|
|
requiresTailgate: dto.requiresTailgate ?? false,
|
|
requiresStraps: dto.requiresStraps ?? false,
|
|
requiresThermalCover: dto.requiresThermalCover ?? false,
|
|
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
|
|
requiresAppointment: dto.requiresAppointment ?? false,
|
|
};
|
|
|
|
// Execute CSV rate search
|
|
const result = await this.csvRateSearchService.execute(searchInput);
|
|
|
|
// Map domain output to response DTO
|
|
const response = this.csvRateMapper.mapSearchOutputToResponseDto(result);
|
|
|
|
const responseTimeMs = Date.now() - startTime;
|
|
this.logger.log(
|
|
`CSV rate search completed: ${response.totalResults} results, ${responseTimeMs}ms`
|
|
);
|
|
|
|
return response;
|
|
} catch (error: any) {
|
|
this.logger.error(
|
|
`CSV rate search failed: ${error?.message || 'Unknown error'}`,
|
|
error?.stack
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search CSV-based rates with service level offers (RAPID, STANDARD, ECONOMIC)
|
|
*/
|
|
@Post('search-csv-offers')
|
|
@UseGuards(JwtAuthGuard)
|
|
@HttpCode(HttpStatus.OK)
|
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
|
@ApiOperation({
|
|
summary: 'Search CSV-based rates with service level offers',
|
|
description:
|
|
'Search for rates from CSV-loaded carriers and generate 3 service level offers for each matching rate: RAPID (20% more expensive, 30% faster), STANDARD (base price and transit), ECONOMIC (15% cheaper, 50% slower). Results are sorted by price (cheapest first).',
|
|
})
|
|
@ApiResponse({
|
|
status: HttpStatus.OK,
|
|
description: 'CSV rate search with offers completed successfully',
|
|
type: CsvRateSearchResponseDto,
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Unauthorized - missing or invalid token',
|
|
})
|
|
@ApiBadRequestResponse({
|
|
description: 'Invalid request parameters',
|
|
})
|
|
async searchCsvRatesWithOffers(
|
|
@Body() dto: CsvRateSearchDto,
|
|
@CurrentUser() user: UserPayload
|
|
): Promise<CsvRateSearchResponseDto> {
|
|
const startTime = Date.now();
|
|
this.logger.log(
|
|
`[User: ${user.email}] Searching CSV rates with offers: ${dto.origin} → ${dto.destination}, ${dto.volumeCBM} CBM, ${dto.weightKG} kg`
|
|
);
|
|
|
|
try {
|
|
// Map DTO to domain input
|
|
const searchInput = {
|
|
origin: dto.origin,
|
|
destination: dto.destination,
|
|
volumeCBM: dto.volumeCBM,
|
|
weightKG: dto.weightKG,
|
|
palletCount: dto.palletCount ?? 0,
|
|
containerType: dto.containerType,
|
|
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
|
|
|
|
// Service requirements for detailed pricing
|
|
hasDangerousGoods: dto.hasDangerousGoods ?? false,
|
|
requiresSpecialHandling: dto.requiresSpecialHandling ?? false,
|
|
requiresTailgate: dto.requiresTailgate ?? false,
|
|
requiresStraps: dto.requiresStraps ?? false,
|
|
requiresThermalCover: dto.requiresThermalCover ?? false,
|
|
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
|
|
requiresAppointment: dto.requiresAppointment ?? false,
|
|
};
|
|
|
|
// Execute CSV rate search WITH OFFERS GENERATION
|
|
const result = await this.csvRateSearchService.executeWithOffers(searchInput);
|
|
|
|
// Map domain output to response DTO
|
|
const response = this.csvRateMapper.mapSearchOutputToResponseDto(result);
|
|
|
|
const responseTimeMs = Date.now() - startTime;
|
|
this.logger.log(
|
|
`CSV rate search with offers completed: ${response.totalResults} results (including 3 offers per rate), ${responseTimeMs}ms`
|
|
);
|
|
|
|
return response;
|
|
} catch (error: any) {
|
|
this.logger.error(
|
|
`CSV rate search with offers failed: ${error?.message || 'Unknown error'}`,
|
|
error?.stack
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get available origin ports from CSV rates
|
|
* Returns only ports that have routes defined in CSV files
|
|
*/
|
|
@Get('available-routes/origins')
|
|
@UseGuards(JwtAuthGuard)
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'Get available origin ports from CSV rates',
|
|
description:
|
|
'Returns list of origin ports that have shipping routes defined in CSV rate files. Use this to populate origin port selection dropdown.',
|
|
})
|
|
@ApiResponse({
|
|
status: HttpStatus.OK,
|
|
description: 'List of available origin ports with details',
|
|
type: AvailableOriginsDto,
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Unauthorized - missing or invalid token',
|
|
})
|
|
async getAvailableOrigins(): Promise<AvailableOriginsDto> {
|
|
this.logger.log('Fetching available origin ports from CSV rates');
|
|
|
|
try {
|
|
// Get unique origin port codes from CSV rates
|
|
const originCodes = await this.csvRateSearchService.getAvailableOrigins();
|
|
|
|
// Fetch port details from database
|
|
const ports = await this.portRepository.findByCodes(originCodes);
|
|
|
|
// Map to response DTO with port details
|
|
const origins: RoutePortInfoDto[] = originCodes.map(code => {
|
|
const port = ports.find(p => p.code === code);
|
|
if (port) {
|
|
return {
|
|
code: port.code,
|
|
name: port.name,
|
|
city: port.city,
|
|
country: port.country,
|
|
countryName: port.countryName,
|
|
displayName: port.getDisplayName(),
|
|
latitude: port.coordinates.latitude,
|
|
longitude: port.coordinates.longitude,
|
|
};
|
|
}
|
|
// Fallback if port not found in database
|
|
return {
|
|
code,
|
|
name: code,
|
|
city: '',
|
|
country: code.substring(0, 2),
|
|
countryName: '',
|
|
displayName: code,
|
|
};
|
|
});
|
|
|
|
// Sort by display name
|
|
origins.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
|
|
return {
|
|
origins,
|
|
total: origins.length,
|
|
};
|
|
} catch (error: any) {
|
|
this.logger.error(
|
|
`Failed to fetch available origins: ${error?.message || 'Unknown error'}`,
|
|
error?.stack
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get available destination ports for a given origin
|
|
* Returns only destinations that have routes from the specified origin in CSV files
|
|
*/
|
|
@Get('available-routes/destinations')
|
|
@UseGuards(JwtAuthGuard)
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'Get available destination ports for a given origin',
|
|
description:
|
|
'Returns list of destination ports that have shipping routes from the specified origin port in CSV rate files. Use this to populate destination port selection dropdown after origin is selected.',
|
|
})
|
|
@ApiQuery({
|
|
name: 'origin',
|
|
required: true,
|
|
description: 'Origin port code (UN/LOCODE format, e.g., NLRTM)',
|
|
example: 'NLRTM',
|
|
})
|
|
@ApiResponse({
|
|
status: HttpStatus.OK,
|
|
description: 'List of available destination ports with details',
|
|
type: AvailableDestinationsDto,
|
|
})
|
|
@ApiResponse({
|
|
status: 401,
|
|
description: 'Unauthorized - missing or invalid token',
|
|
})
|
|
@ApiBadRequestResponse({
|
|
description: 'Origin port code is required',
|
|
})
|
|
async getAvailableDestinations(
|
|
@Query('origin') origin: string
|
|
): Promise<AvailableDestinationsDto> {
|
|
this.logger.log(`Fetching available destinations for origin: ${origin}`);
|
|
|
|
if (!origin) {
|
|
throw new Error('Origin port code is required');
|
|
}
|
|
|
|
try {
|
|
// Get destination port codes for this origin from CSV rates
|
|
const destinationCodes = await this.csvRateSearchService.getAvailableDestinations(origin);
|
|
|
|
// Fetch port details from database
|
|
const ports = await this.portRepository.findByCodes(destinationCodes);
|
|
|
|
// Map to response DTO with port details
|
|
const destinations: RoutePortInfoDto[] = destinationCodes.map(code => {
|
|
const port = ports.find(p => p.code === code);
|
|
if (port) {
|
|
return {
|
|
code: port.code,
|
|
name: port.name,
|
|
city: port.city,
|
|
country: port.country,
|
|
countryName: port.countryName,
|
|
displayName: port.getDisplayName(),
|
|
latitude: port.coordinates.latitude,
|
|
longitude: port.coordinates.longitude,
|
|
};
|
|
}
|
|
// Fallback if port not found in database
|
|
return {
|
|
code,
|
|
name: code,
|
|
city: '',
|
|
country: code.substring(0, 2),
|
|
countryName: '',
|
|
displayName: code,
|
|
};
|
|
});
|
|
|
|
// Sort by display name
|
|
destinations.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
|
|
return {
|
|
origin: origin.toUpperCase(),
|
|
destinations,
|
|
total: destinations.length,
|
|
};
|
|
} catch (error: any) {
|
|
this.logger.error(
|
|
`Failed to fetch available destinations: ${error?.message || 'Unknown error'}`,
|
|
error?.stack
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get available companies
|
|
*/
|
|
@Get('companies')
|
|
@UseGuards(JwtAuthGuard)
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'Get available carrier companies',
|
|
description: 'Returns list of all available carrier companies in the CSV rate system.',
|
|
})
|
|
@ApiResponse({
|
|
status: HttpStatus.OK,
|
|
description: 'List of available companies',
|
|
type: AvailableCompaniesDto,
|
|
})
|
|
async getCompanies(): Promise<AvailableCompaniesDto> {
|
|
this.logger.log('Fetching available companies');
|
|
|
|
try {
|
|
const companies = await this.csvRateSearchService.getAvailableCompanies();
|
|
|
|
return {
|
|
companies,
|
|
total: companies.length,
|
|
};
|
|
} catch (error: any) {
|
|
this.logger.error(
|
|
`Failed to fetch companies: ${error?.message || 'Unknown error'}`,
|
|
error?.stack
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get filter options
|
|
*/
|
|
@Get('filters/options')
|
|
@UseGuards(JwtAuthGuard)
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOperation({
|
|
summary: 'Get available filter options',
|
|
description:
|
|
'Returns available options for all filters (companies, container types, currencies).',
|
|
})
|
|
@ApiResponse({
|
|
status: HttpStatus.OK,
|
|
description: 'Available filter options',
|
|
type: FilterOptionsDto,
|
|
})
|
|
async getFilterOptions(): Promise<FilterOptionsDto> {
|
|
this.logger.log('Fetching filter options');
|
|
|
|
try {
|
|
const [companies, containerTypes] = await Promise.all([
|
|
this.csvRateSearchService.getAvailableCompanies(),
|
|
this.csvRateSearchService.getAvailableContainerTypes(),
|
|
]);
|
|
|
|
return {
|
|
companies,
|
|
containerTypes,
|
|
currencies: ['USD', 'EUR'],
|
|
};
|
|
} catch (error: any) {
|
|
this.logger.error(
|
|
`Failed to fetch filter options: ${error?.message || 'Unknown error'}`,
|
|
error?.stack
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|