331 lines
9.6 KiB
TypeScript
331 lines
9.6 KiB
TypeScript
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<string, string> = 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<CsvRate[]> {
|
|
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<CsvRate[]> {
|
|
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<string[]> {
|
|
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;
|
|
}
|
|
}
|