xpeditis2.0/apps/backend/src/infrastructure/carriers/csv-loader/csv-rate-loader.adapter.ts
David 890bc189ee
Some checks failed
CI/CD Pipeline - Xpeditis PreProd / Frontend - Build & Test (push) Failing after 5m31s
CI/CD Pipeline - Xpeditis PreProd / Frontend - Docker Build & Push (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Backend - Build & Test (push) Failing after 5m42s
CI/CD Pipeline - Xpeditis PreProd / Backend - Docker Build & Push (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Deploy to PreProd Server (push) Has been skipped
CI/CD Pipeline - Xpeditis PreProd / Run Smoke Tests (push) Has been skipped
fix v0.2
2025-11-12 18:00:33 +01:00

334 lines
9.9 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, companyEmail: string): Promise<CsvRate[]> {
this.logger.log(`Loading rates from CSV: ${filePath} (email: ${companyEmail})`);
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, companyEmail);
} 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 [];
}
// Use placeholder email since we don't have access to config repository here
const placeholderEmail = `info@${companyName.toLowerCase().replace(/\s+/g, '-')}.com`;
return this.loadRatesFromCsv(fileName, placeholderEmail);
}
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 (use dummy email for validation)
records.forEach((record, index) => {
try {
this.mapToCsvRate(record, 'validation@example.com');
} 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, companyEmail: string): 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(),
companyEmail,
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;
}
}