import { Injectable, Logger } from '@nestjs/common'; import { parse } from 'csv-parse/sync'; import * as fs from 'fs/promises'; import * as path from 'path'; import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port'; import { CsvRate } from '@domain/entities/csv-rate.entity'; import { PortCode } from '@domain/value-objects/port-code.vo'; import { ContainerType } from '@domain/value-objects/container-type.vo'; import { Money } from '@domain/value-objects/money.vo'; import { Surcharge, SurchargeType, SurchargeCollection } from '@domain/value-objects/surcharge.vo'; import { DateRange } from '@domain/value-objects/date-range.vo'; /** * CSV Row Interface * Maps to CSV file structure */ interface CsvRow { companyName: string; origin: string; destination: string; containerType: string; minVolumeCBM: string; maxVolumeCBM: string; minWeightKG: string; maxWeightKG: string; palletCount: string; pricePerCBM: string; pricePerKG: string; basePriceUSD: string; basePriceEUR: string; currency: string; hasSurcharges: string; surchargeBAF?: string; surchargeCAF?: string; surchargeDetails?: string; transitDays: string; validFrom: string; validUntil: string; } /** * CSV Rate Loader Adapter * * Infrastructure adapter for loading shipping rates from CSV files. * Implements CsvRateLoaderPort interface. * * Features: * - CSV parsing with validation * - Mapping CSV rows to domain entities * - Error handling and logging * - File system operations */ @Injectable() export class CsvRateLoaderAdapter implements CsvRateLoaderPort { private readonly logger = new Logger(CsvRateLoaderAdapter.name); private readonly csvDirectory: string; // Company name to CSV file mapping 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'], ]); constructor() { // CSV files are stored in infrastructure/storage/csv-storage/rates/ // Use absolute path based on project root (works in both dev and production) // In production, process.cwd() points to the backend app directory // In development with nest start --watch, it also points to the backend directory this.csvDirectory = path.join( process.cwd(), 'src', 'infrastructure', 'storage', 'csv-storage', 'rates' ); this.logger.log(`CSV directory initialized: ${this.csvDirectory}`); } async loadRatesFromCsv(filePath: string): Promise { this.logger.log(`Loading rates from CSV: ${filePath}`); try { // Read CSV file const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.csvDirectory, filePath); const fileContent = await fs.readFile(fullPath, 'utf-8'); // Parse CSV const records: CsvRow[] = parse(fileContent, { columns: true, skip_empty_lines: true, trim: true, }); this.logger.log(`Parsed ${records.length} rows from ${filePath}`); // Validate structure this.validateCsvStructure(records); // Map to domain entities const rates = records.map((record, index) => { try { return this.mapToCsvRate(record); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error(`Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`); throw new Error(`Invalid data in row ${index + 1} of ${filePath}: ${errorMessage}`); } }); this.logger.log(`Successfully loaded ${rates.length} rates from ${filePath}`); return rates; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error(`Failed to load CSV file ${filePath}: ${errorMessage}`); throw new Error(`CSV loading failed for ${filePath}: ${errorMessage}`); } } async loadRatesByCompany(companyName: string): Promise { const fileName = this.companyFileMapping.get(companyName); if (!fileName) { this.logger.warn(`No CSV file configured for company: ${companyName}`); return []; } return this.loadRatesFromCsv(fileName); } async validateCsvFile( filePath: string ): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> { const errors: string[] = []; try { const fullPath = path.isAbsolute(filePath) ? filePath : path.join(this.csvDirectory, filePath); // Check if file exists try { await fs.access(fullPath); } catch { errors.push(`File not found: ${filePath}`); return { valid: false, errors }; } // Read and parse const fileContent = await fs.readFile(fullPath, 'utf-8'); const records: CsvRow[] = parse(fileContent, { columns: true, skip_empty_lines: true, trim: true, }); if (records.length === 0) { errors.push('CSV file is empty'); return { valid: false, errors, rowCount: 0 }; } // Validate structure try { this.validateCsvStructure(records); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); errors.push(errorMessage); } // Validate each row records.forEach((record, index) => { try { this.mapToCsvRate(record); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); errors.push(`Row ${index + 1}: ${errorMessage}`); } }); return { valid: errors.length === 0, errors, rowCount: records.length, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); errors.push(`Validation failed: ${errorMessage}`); return { valid: false, errors }; } } async getAvailableCsvFiles(): Promise { try { // Ensure directory exists try { await fs.access(this.csvDirectory); } catch { this.logger.warn(`CSV directory does not exist: ${this.csvDirectory}`); return []; } const files = await fs.readdir(this.csvDirectory); return files.filter(file => file.endsWith('.csv')); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.error(`Failed to list CSV files: ${errorMessage}`); return []; } } /** * Validate that CSV has all required columns */ private validateCsvStructure(records: CsvRow[]): void { const requiredColumns = [ 'companyName', 'origin', 'destination', 'containerType', 'minVolumeCBM', 'maxVolumeCBM', 'minWeightKG', 'maxWeightKG', 'palletCount', 'pricePerCBM', 'pricePerKG', 'basePriceUSD', 'basePriceEUR', 'currency', 'hasSurcharges', 'transitDays', 'validFrom', 'validUntil', ]; if (records.length === 0) { throw new Error('CSV file is empty'); } const firstRecord = records[0]; const missingColumns = requiredColumns.filter(col => !(col in firstRecord)); if (missingColumns.length > 0) { throw new Error(`Missing required columns: ${missingColumns.join(', ')}`); } } /** * Map CSV row to CsvRate domain entity */ private mapToCsvRate(record: CsvRow): CsvRate { // Parse surcharges const surcharges = this.parseSurcharges(record); // Create DateRange const validFrom = new Date(record.validFrom); const validUntil = new Date(record.validUntil); const validity = DateRange.create(validFrom, validUntil, true); // Create CsvRate return new CsvRate( record.companyName.trim(), PortCode.create(record.origin), PortCode.create(record.destination), ContainerType.create(record.containerType), { minCBM: parseFloat(record.minVolumeCBM), maxCBM: parseFloat(record.maxVolumeCBM), }, { minKG: parseFloat(record.minWeightKG), maxKG: parseFloat(record.maxWeightKG), }, parseInt(record.palletCount, 10), { pricePerCBM: parseFloat(record.pricePerCBM), pricePerKG: parseFloat(record.pricePerKG), basePriceUSD: Money.create(parseFloat(record.basePriceUSD), 'USD'), basePriceEUR: Money.create(parseFloat(record.basePriceEUR), 'EUR'), }, record.currency.toUpperCase(), new SurchargeCollection(surcharges), parseInt(record.transitDays, 10), validity ); } /** * Parse surcharges from CSV row */ private parseSurcharges(record: CsvRow): Surcharge[] { const hasSurcharges = record.hasSurcharges.toLowerCase() === 'true'; if (!hasSurcharges) { return []; } const surcharges: Surcharge[] = []; const currency = record.currency.toUpperCase(); // BAF (Bunker Adjustment Factor) if (record.surchargeBAF && parseFloat(record.surchargeBAF) > 0) { surcharges.push( new Surcharge( SurchargeType.BAF, Money.create(parseFloat(record.surchargeBAF), currency), 'Bunker Adjustment Factor' ) ); } // CAF (Currency Adjustment Factor) if (record.surchargeCAF && parseFloat(record.surchargeCAF) > 0) { surcharges.push( new Surcharge( SurchargeType.CAF, Money.create(parseFloat(record.surchargeCAF), currency), 'Currency Adjustment Factor' ) ); } return surcharges; } }