From af9dae72d7652e273cf42801ee7d047b31be6be3 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 13:09:48 +0200 Subject: [PATCH] fix grille tarif --- .../controllers/admin/csv-rates.controller.ts | 88 ++++++--- .../controllers/rates.controller.ts | 77 +++++++- .../application/dto/csv-rate-search.dto.ts | 10 + .../application/dto/csv-rate-upload.dto.ts | 36 +++- .../application/mappers/csv-rate.mapper.ts | 1 + .../src/domain/entities/csv-rate.entity.ts | 6 +- .../domain/ports/in/search-csv-rates.port.ts | 3 +- .../domain/ports/out/csv-rate-loader.port.ts | 7 +- .../services/csv-rate-search.service.ts | 54 +++++- .../csv-loader/csv-rate-loader.adapter.ts | 37 +++- .../carriers/csv-loader/csv-rate.module.ts | 1 + .../entities/csv-rate-config.orm-entity.ts | 12 +- ...5000000000-AddDirectionToCsvRateConfigs.ts | 63 ++++++ .../typeorm-csv-rate-config.repository.ts | 60 ++++-- .../repositories/typeorm-port.repository.ts | 4 + .../app/[locale]/admin/csv-rates/page.tsx | 15 ++ .../dashboard/search-advanced/page.tsx | 182 ++++++++++++++++-- .../search-advanced/results/page.tsx | 12 +- apps/frontend/messages/en.json | 29 ++- apps/frontend/messages/fr.json | 29 ++- .../src/components/admin/CsvUpload.tsx | 38 +++- apps/frontend/src/lib/api/admin/csv-rates.ts | 6 + apps/frontend/src/lib/api/rates.ts | 36 +++- apps/frontend/src/types/api.ts | 2 + apps/frontend/src/types/rates.ts | 2 + 25 files changed, 702 insertions(+), 108 deletions(-) create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/migrations/1755000000000-AddDirectionToCsvRateConfigs.ts diff --git a/apps/backend/src/application/controllers/admin/csv-rates.controller.ts b/apps/backend/src/application/controllers/admin/csv-rates.controller.ts index f7df27c..97d0410 100644 --- a/apps/backend/src/application/controllers/admin/csv-rates.controller.ts +++ b/apps/backend/src/application/controllers/admin/csv-rates.controller.ts @@ -4,6 +4,7 @@ import { Get, Delete, Param, + Query, Body, UseGuards, UseInterceptors, @@ -21,6 +22,7 @@ import { ApiBearerAuth, ApiConsumes, ApiBody, + ApiQuery, } from '@nestjs/swagger'; import { diskStorage } from 'multer'; import { extname } from 'path'; @@ -172,12 +174,15 @@ export class CsvRatesAdminController { } try { - // Generate final filename based on company name + const direction = dto.direction ?? 'EXPORT'; + + // Generate final filename based on company name + direction + // (a company can have one export grid and one import grid) const sanitizedCompanyName = dto.companyName .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, ''); - const finalFilename = `${sanitizedCompanyName}.csv`; + const finalFilename = `${sanitizedCompanyName}-${direction.toLowerCase()}.csv`; // Auto-convert CSV if needed (FOB FRET → Standard format) const conversionResult = await this.csvConverter.autoConvert(file.path, dto.companyName); @@ -207,7 +212,8 @@ export class CsvRatesAdminController { const rates = await this.csvLoader.loadRatesFromCsv( filePathToValidate, dto.companyEmail, - dto.companyName + dto.companyName, + direction ); const ratesCount = rates.length; @@ -241,6 +247,7 @@ export class CsvRatesAdminController { metadata: { companyName: dto.companyName, companyEmail: dto.companyEmail, + direction, uploadedBy: user.email, uploadedAt: new Date().toISOString(), }, @@ -256,13 +263,17 @@ export class CsvRatesAdminController { // The file is still available locally } - // Check if config exists for this company - const existingConfig = await this.csvConfigRepository.findByCompanyName(dto.companyName); + // Check if config exists for this company + direction + const existingConfig = await this.csvConfigRepository.findByCompanyName( + dto.companyName, + direction + ); if (existingConfig) { // Update existing configuration await this.csvConfigRepository.update(existingConfig.id, { csvFilePath: finalFilename, + direction, uploadedAt: new Date(), uploadedBy: user.id, rowCount: ratesCount, @@ -285,6 +296,7 @@ export class CsvRatesAdminController { await this.csvConfigRepository.create({ companyName: dto.companyName, csvFilePath: finalFilename, + direction, type: 'CSV_ONLY', hasApi: false, apiConnector: null, @@ -410,7 +422,13 @@ export class CsvRatesAdminController { @ApiOperation({ summary: 'Delete CSV rate configuration (ADMIN only)', description: - 'Deletes the CSV rate configuration for a company. Note: This does not delete the actual CSV file.', + 'Deletes the CSV rate configuration for a company. Without a direction, both the export and import grids of that company are deleted. Note: This does not delete the actual CSV file.', + }) + @ApiQuery({ + name: 'direction', + required: false, + enum: ['EXPORT', 'IMPORT'], + description: 'Only delete the grid of this direction', }) @ApiResponse({ status: HttpStatus.NO_CONTENT, @@ -422,11 +440,14 @@ export class CsvRatesAdminController { }) async deleteConfig( @Param('companyName') companyName: string, - @CurrentUser() user: UserPayload + @CurrentUser() user: UserPayload, + @Query('direction') direction?: string ): Promise { this.logger.warn(`[Admin: ${user.email}] Deleting CSV config for company: ${companyName}`); - await this.csvConfigRepository.delete(companyName); + const upper = direction?.trim().toUpperCase(); + const scoped = upper === 'EXPORT' || upper === 'IMPORT' ? upper : undefined; + await this.csvConfigRepository.delete(companyName, scoped); this.logger.log(`Deleted CSV config for company: ${companyName}`); } @@ -453,10 +474,12 @@ export class CsvRatesAdminController { items: { type: 'object', properties: { - filename: { type: 'string', example: 'ssc-consolidation.csv' }, + filename: { type: 'string', example: 'ssc-consolidation-export.csv' }, size: { type: 'number', example: 2048 }, uploadedAt: { type: 'string', format: 'date-time' }, rowCount: { type: 'number', example: 150 }, + companyName: { type: 'string', example: 'SSC Consolidation' }, + direction: { type: 'string', enum: ['EXPORT', 'IMPORT'], example: 'EXPORT' }, }, }, }, @@ -468,20 +491,33 @@ export class CsvRatesAdminController { const configs = await this.csvConfigRepository.findAll(); + // Sizes come from MinIO, which is where the grids actually live; the local + // csv-storage copy is only a fallback and is often absent. + const sizeByKey = new Map(); + try { + const bucket = this.configService.get('AWS_S3_BUCKET', 'xpeditis-csv-rates'); + const objects = await this.s3Storage.list(bucket, 'csv-rates/'); + objects.forEach(o => sizeByKey.set(o.key, o.size)); + } catch (error: any) { + this.logger.warn(`Could not list CSV objects from MinIO: ${error.message}`); + } + // Map configs to file info format expected by frontend const files = configs.map(config => { - const filePath = path.join( - process.cwd(), - 'apps/backend/src/infrastructure/storage/csv-storage/rates', - config.csvFilePath - ); + const minioKey = config.metadata?.minioObjectKey as string | undefined; + let fileSize = (minioKey && sizeByKey.get(minioKey)) || 0; - let fileSize = 0; - try { - const stats = fs.statSync(filePath); - fileSize = stats.size; - } catch (error) { - this.logger.warn(`Could not get file size for ${config.csvFilePath}`); + if (!fileSize) { + const filePath = path.join( + process.cwd(), + 'apps/backend/src/infrastructure/storage/csv-storage/rates', + config.csvFilePath + ); + try { + fileSize = fs.statSync(filePath).size; + } catch (error) { + this.logger.warn(`Could not get file size for ${config.csvFilePath}`); + } } return { @@ -490,6 +526,9 @@ export class CsvRatesAdminController { uploadedAt: config.uploadedAt.toISOString(), rowCount: config.rowCount, companyEmail: config.metadata?.companyEmail ?? null, + companyName: config.companyName, + direction: config.direction ?? 'EXPORT', + isActive: config.isActive, }; }); @@ -564,10 +603,13 @@ export class CsvRatesAdminController { } } - // Delete the configuration - await this.csvConfigRepository.delete(config.companyName); + // Delete the configuration — scoped to this grid's direction, otherwise the + // company's other grid (export vs import) would be deleted too. + await this.csvConfigRepository.delete(config.companyName, config.direction); - this.logger.log(`Deleted CSV config and file for: ${config.companyName}`); + this.logger.log( + `Deleted CSV config and file for: ${config.companyName} (${config.direction})` + ); return { success: true, diff --git a/apps/backend/src/application/controllers/rates.controller.ts b/apps/backend/src/application/controllers/rates.controller.ts index d698c3b..0b976d7 100644 --- a/apps/backend/src/application/controllers/rates.controller.ts +++ b/apps/backend/src/application/controllers/rates.controller.ts @@ -33,6 +33,7 @@ import { FilterOptionsDto, AvailableOriginsDto, AvailableDestinationsDto, + AvailableDirectionsDto, RoutePortInfoDto, } from '../dto/csv-rate-upload.dto'; import { CsvRateMapper } from '../mappers/csv-rate.mapper'; @@ -173,6 +174,7 @@ export class RatesController { weightKG: dto.weightKG, containerType: dto.containerType, hasDangerousGoods: dto.hasDangerousGoods ?? false, + direction: dto.direction, filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), }; @@ -237,6 +239,7 @@ export class RatesController { weightKG: dto.weightKG, containerType: dto.containerType, hasDangerousGoods: dto.hasDangerousGoods ?? false, + direction: dto.direction, filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), }; @@ -260,6 +263,42 @@ export class RatesController { } } + /** + * Get the trade directions that currently have usable rate grids. + * Lets the booking wizard skip the import/export step when only one applies. + */ + @Get('available-routes/directions') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get available trade directions', + description: + 'Returns the trade directions (EXPORT / IMPORT) for which at least one usable rate grid exists. An empty or single-entry list means the direction choice can be skipped in the UI.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'List of available trade directions', + type: AvailableDirectionsDto, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - missing or invalid token', + }) + async getAvailableDirections(): Promise { + this.logger.log('Fetching available trade directions from CSV rates'); + + try { + const directions = await this.csvRateSearchService.getAvailableDirections(); + return { directions }; + } catch (error: any) { + this.logger.error( + `Failed to fetch available directions: ${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 @@ -272,6 +311,12 @@ export class RatesController { description: 'Returns list of origin ports that have shipping routes defined in CSV rate files. Use this to populate origin port selection dropdown.', }) + @ApiQuery({ + name: 'direction', + required: false, + enum: ['EXPORT', 'IMPORT'], + description: 'Restrict to grids of this trade direction (EXPORT = French origins)', + }) @ApiResponse({ status: HttpStatus.OK, description: 'List of available origin ports with details', @@ -281,12 +326,18 @@ export class RatesController { status: 401, description: 'Unauthorized - missing or invalid token', }) - async getAvailableOrigins(): Promise { - this.logger.log('Fetching available origin ports from CSV rates'); + async getAvailableOrigins( + @Query('direction') direction?: string + ): Promise { + this.logger.log( + `Fetching available origin ports from CSV rates${direction ? ` (${direction})` : ''}` + ); try { // Get unique origin port codes from CSV rates - const originCodes = await this.csvRateSearchService.getAvailableOrigins(); + const originCodes = await this.csvRateSearchService.getAvailableOrigins( + this.parseDirection(direction) + ); // Fetch port details from database const ports = await this.portRepository.findByCodes(originCodes); @@ -360,11 +411,18 @@ export class RatesController { status: 401, description: 'Unauthorized - missing or invalid token', }) + @ApiQuery({ + name: 'direction', + required: false, + enum: ['EXPORT', 'IMPORT'], + description: 'Restrict to grids of this trade direction (EXPORT = French origins)', + }) @ApiBadRequestResponse({ description: 'Origin port code is required', }) async getAvailableDestinations( - @Query('origin') origin: string + @Query('origin') origin: string, + @Query('direction') direction?: string ): Promise { this.logger.log(`Fetching available destinations for origin: ${origin}`); @@ -374,7 +432,10 @@ export class RatesController { try { // Get destination port codes for this origin from CSV rates - const destinationCodes = await this.csvRateSearchService.getAvailableDestinations(origin); + const destinationCodes = await this.csvRateSearchService.getAvailableDestinations( + origin, + this.parseDirection(direction) + ); // Fetch port details from database const ports = await this.portRepository.findByCodes(destinationCodes); @@ -494,4 +555,10 @@ export class RatesController { throw error; } } + + /** Normalize the direction query param; anything unrecognized means "no filter". */ + private parseDirection(direction?: string): 'EXPORT' | 'IMPORT' | undefined { + const upper = direction?.trim().toUpperCase(); + return upper === 'EXPORT' || upper === 'IMPORT' ? upper : undefined; + } } diff --git a/apps/backend/src/application/dto/csv-rate-search.dto.ts b/apps/backend/src/application/dto/csv-rate-search.dto.ts index da490f7..ff61cba 100644 --- a/apps/backend/src/application/dto/csv-rate-search.dto.ts +++ b/apps/backend/src/application/dto/csv-rate-search.dto.ts @@ -7,6 +7,7 @@ import { IsOptional, ValidateNested, IsBoolean, + IsIn, } from 'class-validator'; import { Type } from 'class-transformer'; import { RateSearchFiltersDto } from './rate-search-filters.dto'; @@ -44,6 +45,15 @@ export class CsvRateSearchDto { @IsBoolean() hasDangerousGoods?: boolean; + @ApiPropertyOptional({ + description: 'Trade direction: EXPORT (French origin) or IMPORT (French destination)', + enum: ['EXPORT', 'IMPORT'], + example: 'EXPORT', + }) + @IsOptional() + @IsIn(['EXPORT', 'IMPORT']) + direction?: 'EXPORT' | 'IMPORT'; + @ApiPropertyOptional({ description: 'Advanced filters', type: RateSearchFiltersDto }) @IsOptional() @ValidateNested() diff --git a/apps/backend/src/application/dto/csv-rate-upload.dto.ts b/apps/backend/src/application/dto/csv-rate-upload.dto.ts index be38e08..76138b4 100644 --- a/apps/backend/src/application/dto/csv-rate-upload.dto.ts +++ b/apps/backend/src/application/dto/csv-rate-upload.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString, MaxLength, IsEmail } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength, IsEmail, IsOptional, IsIn } from 'class-validator'; /** * CSV Rate Upload DTO @@ -27,6 +27,16 @@ export class CsvRateUploadDto { @MaxLength(255) companyEmail: string; + @ApiPropertyOptional({ + description: + 'Trade direction of the grid: EXPORT (French origins) or IMPORT (French destinations). Defaults to EXPORT.', + enum: ['EXPORT', 'IMPORT'], + example: 'EXPORT', + }) + @IsOptional() + @IsIn(['EXPORT', 'IMPORT']) + direction?: 'EXPORT' | 'IMPORT'; + @ApiProperty({ description: 'CSV file containing shipping rates', type: 'string', @@ -90,10 +100,17 @@ export class CsvRateConfigDto { @ApiProperty({ description: 'CSV file path', - example: 'ssc-consolidation.csv', + example: 'ssc-consolidation-export.csv', }) csvFilePath: string; + @ApiProperty({ + description: 'Trade direction of the grid', + enum: ['EXPORT', 'IMPORT'], + example: 'EXPORT', + }) + direction: 'EXPORT' | 'IMPORT'; + @ApiProperty({ description: 'Integration type', enum: ['CSV_ONLY', 'CSV_AND_API'], @@ -184,6 +201,19 @@ export class AvailableCompaniesDto { total: number; } +/** + * Available Directions Response DTO + */ +export class AvailableDirectionsDto { + @ApiProperty({ + description: 'Trade directions that currently have usable rate grids', + type: [String], + enum: ['EXPORT', 'IMPORT'], + example: ['EXPORT'], + }) + directions: ('EXPORT' | 'IMPORT')[]; +} + /** * Filter Options Response DTO */ diff --git a/apps/backend/src/application/mappers/csv-rate.mapper.ts b/apps/backend/src/application/mappers/csv-rate.mapper.ts index f7bdf26..1af7f80 100644 --- a/apps/backend/src/application/mappers/csv-rate.mapper.ts +++ b/apps/backend/src/application/mappers/csv-rate.mapper.ts @@ -105,6 +105,7 @@ export class CsvRateMapper { id: entity.id, companyName: entity.companyName, csvFilePath: entity.csvFilePath, + direction: entity.direction ?? 'EXPORT', type: entity.type, hasApi: entity.hasApi, apiConnector: entity.apiConnector, diff --git a/apps/backend/src/domain/entities/csv-rate.entity.ts b/apps/backend/src/domain/entities/csv-rate.entity.ts index 89de1ef..abbef47 100644 --- a/apps/backend/src/domain/entities/csv-rate.entity.ts +++ b/apps/backend/src/domain/entities/csv-rate.entity.ts @@ -5,6 +5,8 @@ import { DateRange } from '../value-objects/date-range.vo'; export type DgSurchargeValue = number | 'ON REQUEST' | 'NOT ACCEPTED'; export type HandlingUnit = 'W' | 'UP'; // W = tonne revenue (max CBM/T), UP = per CBM export type FrequencyType = 'Weekly' | 'Bi-Weekly' | 'Bi-Monthly' | 'Monthly'; +// EXPORT = French origin, foreign destination; IMPORT = the reverse +export type RateDirection = 'EXPORT' | 'IMPORT'; export interface FreightPricing { freightCurrency: string; @@ -65,7 +67,9 @@ export class CsvRate { public readonly remarks: string, public readonly frequency: FrequencyType, public readonly transitDays: number, - public readonly validity: DateRange + public readonly validity: DateRange, + // Trade direction of the grid this rate comes from (config-level property) + public readonly direction: RateDirection = 'EXPORT' ) { this.validate(); } diff --git a/apps/backend/src/domain/ports/in/search-csv-rates.port.ts b/apps/backend/src/domain/ports/in/search-csv-rates.port.ts index ce16fd1..1ac3225 100644 --- a/apps/backend/src/domain/ports/in/search-csv-rates.port.ts +++ b/apps/backend/src/domain/ports/in/search-csv-rates.port.ts @@ -1,4 +1,4 @@ -import { CsvRate } from '../../entities/csv-rate.entity'; +import { CsvRate, RateDirection } from '../../entities/csv-rate.entity'; import { ServiceLevel } from '../../services/rate-offer-generator.service'; import { PriceBreakdown } from '../../services/csv-rate-price-calculator.service'; @@ -43,6 +43,7 @@ export interface CsvRateSearchInput { weightKG: number; containerType?: string; hasDangerousGoods?: boolean; + direction?: RateDirection; // EXPORT | IMPORT — restricts to grids of that direction filters?: RateSearchFilters; } diff --git a/apps/backend/src/domain/ports/out/csv-rate-loader.port.ts b/apps/backend/src/domain/ports/out/csv-rate-loader.port.ts index 54c3050..effe5f6 100644 --- a/apps/backend/src/domain/ports/out/csv-rate-loader.port.ts +++ b/apps/backend/src/domain/ports/out/csv-rate-loader.port.ts @@ -1,4 +1,4 @@ -import { CsvRate } from '../../entities/csv-rate.entity'; +import { CsvRate, RateDirection } from '../../entities/csv-rate.entity'; /** * CSV Rate Loader Port (Output Port) @@ -14,13 +14,16 @@ export interface CsvRateLoaderPort { * @param filePath - Absolute or relative path to CSV file * @param companyEmail - Email address for the company (stored in config metadata) * @param companyNameOverride - Optional company name to override the one in CSV file + * @param direction - Trade direction of the grid (defaults to the config's + * direction, then to the filename suffix -export/-import, then EXPORT) * @returns Array of CSV rates * @throws Error if file cannot be read or parsed */ loadRatesFromCsv( filePath: string, companyEmail: string, - companyNameOverride?: string + companyNameOverride?: string, + direction?: RateDirection ): Promise; /** diff --git a/apps/backend/src/domain/services/csv-rate-search.service.ts b/apps/backend/src/domain/services/csv-rate-search.service.ts index 338e77f..7e9760e 100644 --- a/apps/backend/src/domain/services/csv-rate-search.service.ts +++ b/apps/backend/src/domain/services/csv-rate-search.service.ts @@ -1,4 +1,4 @@ -import { CsvRate } from '../entities/csv-rate.entity'; +import { CsvRate, RateDirection } from '../entities/csv-rate.entity'; import { PortCode } from '../value-objects/port-code.vo'; import { ContainerType } from '../value-objects/container-type.vo'; import { @@ -15,6 +15,7 @@ import { RateOfferGeneratorService } from './rate-offer-generator.service'; interface CsvRateConfig { companyName: string; csvFilePath: string; + direction?: RateDirection; metadata?: { companyEmail?: string; [key: string]: any; @@ -45,6 +46,10 @@ export class CsvRateSearchService implements SearchCsvRatesPort { const allRates = await this.loadAllRates(); let matchingRates = this.filterByRoute(allRates, origin, destination); + if (input.direction) { + matchingRates = matchingRates.filter(rate => rate.direction === input.direction); + } + if (input.containerType) { const containerType = ContainerType.create(input.containerType); matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType)); @@ -94,6 +99,10 @@ export class CsvRateSearchService implements SearchCsvRatesPort { const allRates = await this.loadAllRates(); let matchingRates = this.filterByRoute(allRates, origin, destination); + if (input.direction) { + matchingRates = matchingRates.filter(rate => rate.direction === input.direction); + } + if (input.containerType) { const containerType = ContainerType.create(input.containerType); matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType)); @@ -164,26 +173,50 @@ export class CsvRateSearchService implements SearchCsvRatesPort { return [...new Set(allRates.map(r => r.containerType.getValue()))].sort(); } - async getAvailableOrigins(): Promise { + /** + * Route dropdowns must only propose routes the search can actually serve: + * same eligibility rules as executeWithOffers (valid today, freight > 0), + * otherwise expired grids fill the dropdowns but every search returns 0 results. + * When a direction is given, only rates from grids of that direction are kept. + */ + private async loadEligibleRates(direction?: RateDirection): Promise { const allRates = await this.loadAllRates(); - return [...new Set(allRates.map(r => r.originCode.getValue()))].sort(); + const eligible = this.offerGenerator.filterEligibleRates(allRates); + return direction ? eligible.filter(r => r.direction === direction) : eligible; } - async getAvailableDestinations(origin: string): Promise { - const allRates = await this.loadAllRates(); + /** + * Directions actually served today. Lets the UI drop the import/export step + * entirely when only one direction has usable grids. + */ + async getAvailableDirections(): Promise { + const eligibleRates = await this.loadEligibleRates(); + const directions = new Set(eligibleRates.map(r => r.direction)); + return (['EXPORT', 'IMPORT'] as RateDirection[]).filter(d => directions.has(d)); + } + + async getAvailableOrigins(direction?: RateDirection): Promise { + const eligibleRates = await this.loadEligibleRates(direction); + return [...new Set(eligibleRates.map(r => r.originCode.getValue()))].sort(); + } + + async getAvailableDestinations(origin: string, direction?: RateDirection): Promise { + const eligibleRates = await this.loadEligibleRates(direction); const originCode = PortCode.create(origin); return [ ...new Set( - allRates.filter(r => r.originCode.equals(originCode)).map(r => r.destinationCode.getValue()) + eligibleRates + .filter(r => r.originCode.equals(originCode)) + .map(r => r.destinationCode.getValue()) ), ].sort(); } - async getAvailableRoutes(): Promise> { - const allRates = await this.loadAllRates(); + async getAvailableRoutes(direction?: RateDirection): Promise> { + const eligibleRates = await this.loadEligibleRates(direction); const routeMap = new Map>(); - allRates.forEach(rate => { + eligibleRates.forEach(rate => { const origin = rate.originCode.getValue(); const destination = rate.destinationCode.getValue(); if (!routeMap.has(origin)) routeMap.set(origin, new Set()); @@ -208,7 +241,8 @@ export class CsvRateSearchService implements SearchCsvRatesPort { return this.csvRateLoader.loadRatesFromCsv( config.csvFilePath, email, - config.companyName + config.companyName, + config.direction ); }) ); diff --git a/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts b/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts index 96c211b..90d0154 100644 --- a/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts +++ b/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts @@ -12,6 +12,7 @@ import { DgSurchargeValue, HandlingUnit, FrequencyType, + RateDirection, } from '@domain/entities/csv-rate.entity'; import { PortCode } from '@domain/value-objects/port-code.vo'; import { ContainerType } from '@domain/value-objects/container-type.vo'; @@ -107,11 +108,13 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort { private readonly logger = new Logger(CsvRateLoaderAdapter.name); private readonly csvDirectory: string; + // Legacy fallback mapping (export grids). Files are suffixed by direction: + // -export.csv / -import.csv. private readonly companyFileMapping: Map = new Map([ - ['SSC Consolidation', 'ssc-consolidation.csv'], - ['ECU Worldwide', 'ecu-worldwide.csv'], - ['TCC Logistics', 'tcc-logistics.csv'], - ['NVO Consolidation', 'nvo-consolidation.csv'], + ['SSC Consolidation', 'ssc-consolidation-export.csv'], + ['ECU Worldwide', 'ecu-worldwide-export.csv'], + ['TCC Logistics', 'tcc-logistics-export.csv'], + ['NVO Consolidation', 'nvo-consolidation-export.csv'], ]); constructor( @@ -133,16 +136,22 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort { async loadRatesFromCsv( filePath: string, companyEmail: string, - companyNameOverride?: string + companyNameOverride?: string, + direction?: RateDirection ): Promise { this.logger.log(`Loading rates from CSV: ${filePath}`); try { let fileContent: string; + let resolvedDirection: RateDirection | undefined = direction; if (this.s3Storage && this.configService && this.csvConfigRepository && companyNameOverride) { try { - const config = await this.csvConfigRepository.findByCompanyName(companyNameOverride); + const config = await this.csvConfigRepository.findByCompanyName( + companyNameOverride, + direction + ); + resolvedDirection = resolvedDirection ?? config?.direction ?? undefined; const minioObjectKey = config?.metadata?.minioObjectKey as string | undefined; if (minioObjectKey) { const bucket = this.configService.get('AWS_S3_BUCKET', 'xpeditis-csv-rates'); @@ -161,6 +170,10 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort { fileContent = await fs.readFile(fullPath, 'utf-8'); } + // Last-resort direction: filename suffix (-import.csv), default EXPORT + resolvedDirection = + resolvedDirection ?? (/-import\.csv$/i.test(filePath) ? 'IMPORT' : 'EXPORT'); + const records: CsvRow[] = parse(fileContent, { columns: true, skip_empty_lines: true, @@ -172,7 +185,7 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort { const rates = records.map((record, index) => { try { - return this.mapToCsvRate(record, companyEmail, companyNameOverride); + return this.mapToCsvRate(record, companyEmail, companyNameOverride, resolvedDirection); } catch (error) { const msg = error instanceof Error ? error.message : String(error); throw new Error(`Row ${index + 1} in ${filePath}: ${msg}`); @@ -284,7 +297,12 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort { } } - private mapToCsvRate(r: CsvRow, companyEmail: string, companyNameOverride?: string): CsvRate { + private mapToCsvRate( + r: CsvRow, + companyEmail: string, + companyNameOverride?: string, + direction: RateDirection = 'EXPORT' + ): CsvRate { const companyName = companyNameOverride || r.companyName.trim(); // Admin-configured email always takes priority over the value in the CSV row const email = companyEmail?.trim() || r.companyEmail?.trim(); @@ -341,7 +359,8 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort { r.remarks?.trim() || '', frequency, parseInt(r.transitDays, 10), - validity + validity, + direction ); } } diff --git a/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate.module.ts b/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate.module.ts index dd52b18..d5ab467 100644 --- a/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate.module.ts +++ b/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate.module.ts @@ -60,6 +60,7 @@ import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/enti return configs.map(config => ({ companyName: config.companyName, csvFilePath: config.csvFilePath, + direction: config.direction, metadata: config.metadata === null ? undefined : config.metadata, })); }, diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity.ts index a40b036..25a9e6f 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity.ts @@ -6,26 +6,34 @@ import { UpdateDateColumn, ManyToOne, JoinColumn, + Unique, } from 'typeorm'; import { UserOrmEntity } from './user.orm-entity'; +export type CsvRateDirection = 'EXPORT' | 'IMPORT'; + /** * CSV Rate Config ORM Entity * * Stores configuration for CSV-based shipping rates - * Maps company names to their CSV files + * Maps company names to their CSV files. + * A company can have one grid per direction (export and import). */ @Entity('csv_rate_configs') +@Unique('uq_csv_rate_configs_company_direction', ['companyName', 'direction']) export class CsvRateConfigOrmEntity { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ name: 'company_name', type: 'varchar', length: 255, unique: true }) + @Column({ name: 'company_name', type: 'varchar', length: 255 }) companyName: string; @Column({ name: 'csv_file_path', type: 'varchar', length: 500 }) csvFilePath: string; + @Column({ name: 'direction', type: 'varchar', length: 10, default: 'EXPORT' }) + direction: CsvRateDirection; + @Column({ name: 'type', type: 'varchar', diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1755000000000-AddDirectionToCsvRateConfigs.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1755000000000-AddDirectionToCsvRateConfigs.ts new file mode 100644 index 0000000..84a38ab --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1755000000000-AddDirectionToCsvRateConfigs.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds trade direction (EXPORT | IMPORT) to CSV rate configs. + * + * A grid file covers exactly one direction; a company can now provide one + * export grid AND one import grid, so the unique constraint moves from + * (company_name) to (company_name, direction). Existing rows are export + * grids (French origins) and get the EXPORT default. + */ +export class AddDirectionToCsvRateConfigs1755000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "csv_rate_configs" + ADD COLUMN "direction" VARCHAR(10) NOT NULL DEFAULT 'EXPORT' + `); + + await queryRunner.query(` + ALTER TABLE "csv_rate_configs" + ADD CONSTRAINT "chk_csv_rate_configs_direction" + CHECK ("direction" IN ('EXPORT', 'IMPORT')) + `); + + await queryRunner.query(` + ALTER TABLE "csv_rate_configs" + DROP CONSTRAINT IF EXISTS "uq_csv_rate_configs_company" + `); + + await queryRunner.query(` + ALTER TABLE "csv_rate_configs" + ADD CONSTRAINT "uq_csv_rate_configs_company_direction" + UNIQUE ("company_name", "direction") + `); + + await queryRunner.query(` + COMMENT ON COLUMN "csv_rate_configs"."direction" + IS 'Trade direction of the grid: EXPORT (French origins) or IMPORT (French destinations)' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "csv_rate_configs" + DROP CONSTRAINT IF EXISTS "uq_csv_rate_configs_company_direction" + `); + + await queryRunner.query(` + ALTER TABLE "csv_rate_configs" + ADD CONSTRAINT "uq_csv_rate_configs_company" + UNIQUE ("company_name") + `); + + await queryRunner.query(` + ALTER TABLE "csv_rate_configs" + DROP CONSTRAINT IF EXISTS "chk_csv_rate_configs_direction" + `); + + await queryRunner.query(` + ALTER TABLE "csv_rate_configs" + DROP COLUMN "direction" + `); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository.ts index 59acf4c..fe03619 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository.ts @@ -1,21 +1,29 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { CsvRateConfigOrmEntity } from '../entities/csv-rate-config.orm-entity'; +import { + CsvRateConfigOrmEntity, + CsvRateDirection, +} from '../entities/csv-rate-config.orm-entity'; /** * CSV Rate Config Repository Port * - * Interface for CSV rate configuration operations + * Interface for CSV rate configuration operations. + * A company can hold one config per direction (EXPORT / IMPORT); lookups by + * company name accept an optional direction to disambiguate. */ export interface CsvRateConfigRepositoryPort { findAll(): Promise; - findByCompanyName(companyName: string): Promise; + findByCompanyName( + companyName: string, + direction?: CsvRateDirection + ): Promise; findActiveConfigs(): Promise; create(config: Partial): Promise; update(id: string, config: Partial): Promise; - delete(companyName: string): Promise; - exists(companyName: string): Promise; + delete(companyName: string, direction?: CsvRateDirection): Promise; + exists(companyName: string, direction?: CsvRateDirection): Promise; } /** @@ -43,12 +51,17 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo } /** - * Find configuration by company name + * Find configuration by company name (optionally scoped to a direction) */ - async findByCompanyName(companyName: string): Promise { - this.logger.log(`Finding CSV rate config for company: ${companyName}`); + async findByCompanyName( + companyName: string, + direction?: CsvRateDirection + ): Promise { + this.logger.log( + `Finding CSV rate config for company: ${companyName}${direction ? ` (${direction})` : ''}` + ); return this.repository.findOne({ - where: { companyName }, + where: direction ? { companyName, direction } : { companyName }, }); } @@ -69,10 +82,15 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo async create(config: Partial): Promise { this.logger.log(`Creating CSV rate config for company: ${config.companyName}`); - // Check if company already exists - const existing = await this.findByCompanyName(config.companyName!); + // Check if company already exists for this direction + const existing = await this.findByCompanyName( + config.companyName!, + config.direction ?? 'EXPORT' + ); if (existing) { - throw new Error(`CSV rate config already exists for company: ${config.companyName}`); + throw new Error( + `CSV rate config already exists for company: ${config.companyName} (${config.direction ?? 'EXPORT'})` + ); } const entity = this.repository.create({ @@ -107,12 +125,16 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo } /** - * Delete CSV rate configuration by company name + * Delete CSV rate configuration by company name (optionally one direction only) */ - async delete(companyName: string): Promise { - this.logger.log(`Deleting CSV rate config for company: ${companyName}`); + async delete(companyName: string, direction?: CsvRateDirection): Promise { + this.logger.log( + `Deleting CSV rate config for company: ${companyName}${direction ? ` (${direction})` : ''}` + ); - const result = await this.repository.delete({ companyName }); + const result = await this.repository.delete( + direction ? { companyName, direction } : { companyName } + ); if (result.affected === 0) { throw new Error(`CSV rate config not found for company: ${companyName}`); @@ -122,11 +144,11 @@ export class TypeOrmCsvRateConfigRepository implements CsvRateConfigRepositoryPo } /** - * Check if configuration exists for company + * Check if configuration exists for company (optionally for a direction) */ - async exists(companyName: string): Promise { + async exists(companyName: string, direction?: CsvRateDirection): Promise { const count = await this.repository.count({ - where: { companyName }, + where: direction ? { companyName, direction } : { companyName }, }); return count > 0; } diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-port.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-port.repository.ts index 89c6652..8eed750 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-port.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-port.repository.ts @@ -39,6 +39,10 @@ export class TypeOrmPortRepository implements PortRepository { } async findByCodes(codes: string[]): Promise { + // An empty list would render as `IN ()`, which TypeORM rejects — happens + // whenever a route lookup yields no codes (e.g. a direction with no grid). + if (codes.length === 0) return []; + const upperCodes = codes.map(c => c.toUpperCase()); const orms = await this.repository .createQueryBuilder('port') diff --git a/apps/frontend/app/[locale]/admin/csv-rates/page.tsx b/apps/frontend/app/[locale]/admin/csv-rates/page.tsx index a3c720b..cf97e70 100644 --- a/apps/frontend/app/[locale]/admin/csv-rates/page.tsx +++ b/apps/frontend/app/[locale]/admin/csv-rates/page.tsx @@ -27,6 +27,11 @@ import { TableRow, } from '@/components/ui/table'; +const DIRECTION_COLORS: Record<'EXPORT' | 'IMPORT', string> = { + EXPORT: 'bg-blue-100 text-blue-800', + IMPORT: 'bg-purple-100 text-purple-800', +}; + export default function AdminCsvRatesPage() { const t = useTranslations('dashboard.admin.csvRates'); const locale = useLocale(); @@ -118,6 +123,7 @@ export default function AdminCsvRatesPage() { {t('table.filename')} + {t('table.direction')} {t('table.size')} {t('table.rows')} {t('table.uploadedAt')} @@ -131,6 +137,15 @@ export default function AdminCsvRatesPage() { {file.filename} + + + {file.direction === 'IMPORT' ? 'Import' : 'Export'} + + {(file.size / 1024).toFixed(2)} KB {file.rowCount ? ( diff --git a/apps/frontend/app/[locale]/dashboard/search-advanced/page.tsx b/apps/frontend/app/[locale]/dashboard/search-advanced/page.tsx index f7fdcf5..10ae96b 100644 --- a/apps/frontend/app/[locale]/dashboard/search-advanced/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/search-advanced/page.tsx @@ -3,10 +3,15 @@ import { useState, useEffect } from 'react'; import { useRouter } from '@/i18n/navigation'; import { useSearchParams } from 'next/navigation'; -import { Search, Loader2, Lock, Sparkles } from 'lucide-react'; +import { Search, Loader2, Lock, Sparkles, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { useTranslations } from 'next-intl'; -import { getAvailableOrigins, getAvailableDestinations, RoutePortInfo } from '@/lib/api/rates'; +import { + getAvailableOrigins, + getAvailableDestinations, + getAvailableDirections, + RoutePortInfo, +} from '@/lib/api/rates'; import { useReservationQuota } from '@/hooks/useReservationQuota'; import { Link } from '@/i18n/navigation'; import dynamic from 'next/dynamic'; @@ -25,6 +30,8 @@ const PortRouteMap = dynamic(() => import('@/components/PortRouteMap'), { loading: PortRouteMapLoader, }); +export type TradeDirection = 'EXPORT' | 'IMPORT'; + interface Package { type: 'caisse' | 'colis' | 'palette' | 'autre'; quantity: number; @@ -104,7 +111,11 @@ export default function AdvancedSearchPage() { t1Document: false, }); - const [currentStep, setCurrentStep] = useState(1); + // Step 0 = import/export choice, 1 = ports, 2 = cargo, 3 = options + const [currentStep, setCurrentStep] = useState(0); + // Trade direction must be chosen before the origin/destination step: + // EXPORT = French origins, IMPORT = French destinations. + const [direction, setDirection] = useState(null); const [originSearch, setOriginSearch] = useState(''); const [destinationSearch, setDestinationSearch] = useState(''); const [showOriginDropdown, setShowOriginDropdown] = useState(false); @@ -114,15 +125,40 @@ export default function AdvancedSearchPage() { null ); + const { data: directionsData, isLoading: isLoadingDirections } = useQuery({ + queryKey: ['available-directions'], + queryFn: getAvailableDirections, + }); + + const availableDirections = directionsData?.directions ?? []; + // Asking "import or export?" is only meaningful when both are actually + // served; with a single direction the step is dropped and auto-selected. + const showDirectionStep = availableDirections.length > 1; + const wizardSteps = showDirectionStep ? [0, 1, 2, 3] : [1, 2, 3]; + const firstStep = wizardSteps[0]; + const stepNumber = (step: number) => wizardSteps.indexOf(step) + 1; + + useEffect(() => { + if (!directionsData) return; + if (availableDirections.length === 1) { + setDirection(prev => prev ?? availableDirections[0]); + } + if (!showDirectionStep) { + setCurrentStep(prev => (prev === 0 ? 1 : prev)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [directionsData]); + const { data: originsData, isLoading: isLoadingOrigins } = useQuery({ - queryKey: ['available-origins'], - queryFn: getAvailableOrigins, + queryKey: ['available-origins', direction], + queryFn: () => getAvailableOrigins(direction ?? undefined), + enabled: !isLoadingDirections && (!!direction || !showDirectionStep), }); const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({ - queryKey: ['available-destinations', searchForm.origin], - queryFn: () => getAvailableDestinations(searchForm.origin), - enabled: !!searchForm.origin, + queryKey: ['available-destinations', searchForm.origin, direction], + queryFn: () => getAvailableDestinations(searchForm.origin, direction ?? undefined), + enabled: !!searchForm.origin && !isLoadingDirections, }); const filteredOrigins = (originsData?.origins || []).filter(port => { @@ -166,8 +202,20 @@ export default function AdvancedSearchPage() { const originParam = searchParams.get('origin'); const destinationParam = searchParams.get('destination'); const editId = searchParams.get('editBookingId'); + const directionParam = searchParams.get('direction'); + if (directionParam === 'EXPORT' || directionParam === 'IMPORT') { + setDirection(directionParam); + } if (!originParam && !destinationParam && !editId) return; + // Deep links and booking edits may carry no direction (bookings don't store + // it): infer it from which end of the lane is French. + if (directionParam !== 'EXPORT' && directionParam !== 'IMPORT') { + setDirection(destinationParam?.startsWith('FR') && !originParam?.startsWith('FR') + ? 'IMPORT' + : 'EXPORT'); + } + if (editId) setEditBookingId(editId); const vol = parseFloat(searchParams.get('volumeCBM') || '0'); @@ -295,6 +343,10 @@ export default function AdvancedSearchPage() { requiresAppointment: searchForm.appointment.toString(), }); + if (direction) { + params.set('direction', direction); + } + if (editBookingId) { params.set('editBookingId', editBookingId); } @@ -337,9 +389,84 @@ export default function AdvancedSearchPage() { setSearchForm({ ...searchForm, packages: newPackages }); }; + const selectDirection = (value: TradeDirection) => { + // Switching direction invalidates the picked ports (an export origin is + // never a valid import origin); re-picking the same one keeps them. + if (value !== direction) { + setSearchForm({ ...searchForm, origin: '', destination: '' }); + setSelectedOriginPort(null); + setSelectedDestinationPort(null); + setOriginSearch(''); + setDestinationSearch(''); + } + setDirection(value); + setCurrentStep(1); + }; + + const renderStep0 = () => ( +
+
+

+ {stepNumber(0)}. {t('step0.title')} +

+

{t('step0.subtitle')}

+
+ +
+ {( + [ + { value: 'EXPORT' as const, Icon: ArrowUpFromLine }, + { value: 'IMPORT' as const, Icon: ArrowDownToLine }, + ] as const + ) + .filter(({ value }) => availableDirections.includes(value)) + .map(({ value, Icon }) => { + const isSelected = direction === value; + const key = value.toLowerCase() as 'export' | 'import'; + return ( + + ); + })} +
+
+ ); + const renderStep1 = () => (
-

{t('step1.title')}

+
+

+ {stepNumber(1)}. {t('step1.title')} +

+ {direction && showDirectionStep && ( + + )} +
@@ -541,7 +668,9 @@ export default function AdvancedSearchPage() { return (
-

{t('step2.title')}

+

+ {stepNumber(2)}. {t('step2.title')} +

+ {/* Trade direction */} +
+ +
+ {( + [ + { value: 'EXPORT' as const, label: t('form.directionExport') }, + { value: 'IMPORT' as const, label: t('form.directionImport') }, + ] as const + ).map(option => ( + + ))} +
+

{t('form.directionHint')}

+
+ {/* File Input */}