237 lines
7.9 KiB
TypeScript
237 lines
7.9 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
|
|
/**
|
|
* CSV Converter Service
|
|
*
|
|
* Detects and converts CSV files to the standard 33-column Xpeditis format.
|
|
*
|
|
* Standard format columns (33):
|
|
* companyName, companyEmail, originCFS, originCode, portOfLoading, routing,
|
|
* destinationCFS, destinationCode, destinationCountry, containerType,
|
|
* freightCurrency, freightRatePerCBM, freightMinimum,
|
|
* fobCurrency, fobDocumentation, fobISPS, fobHandling, fobHandlingUnit,
|
|
* fobHandlingMinimum, fobSolas, fobCustoms, fobAMS_ACI, fobISF5, fobDGAdmin,
|
|
* dgSurchargeCurrency, dgSurchargeRate, dgSurchargeUnit, dgSurchargeMin,
|
|
* remarks, frequency, transitDays, validFrom, validUntil
|
|
*/
|
|
@Injectable()
|
|
export class CsvConverterService {
|
|
private readonly logger = new Logger(CsvConverterService.name);
|
|
|
|
private readonly STANDARD_HEADERS = [
|
|
'companyName',
|
|
'companyEmail',
|
|
'originCFS',
|
|
'originCode',
|
|
'portOfLoading',
|
|
'routing',
|
|
'destinationCFS',
|
|
'destinationCode',
|
|
'destinationCountry',
|
|
'containerType',
|
|
'freightCurrency',
|
|
'freightRatePerCBM',
|
|
'freightMinimum',
|
|
'fobCurrency',
|
|
'fobDocumentation',
|
|
'fobISPS',
|
|
'fobHandling',
|
|
'fobHandlingUnit',
|
|
'fobHandlingMinimum',
|
|
'fobSolas',
|
|
'fobCustoms',
|
|
'fobAMS_ACI',
|
|
'fobISF5',
|
|
'fobDGAdmin',
|
|
'dgSurchargeCurrency',
|
|
'dgSurchargeRate',
|
|
'dgSurchargeUnit',
|
|
'dgSurchargeMin',
|
|
'remarks',
|
|
'frequency',
|
|
'transitDays',
|
|
'validFrom',
|
|
'validUntil',
|
|
];
|
|
|
|
// Legacy "Frais FOB FRET" format indicators (older Excel exports)
|
|
private readonly FOB_FRET_HEADERS = [
|
|
'Origine UN code',
|
|
'Destination UN code',
|
|
'Devise FRET',
|
|
'Taux de FRET (UP)',
|
|
'Minimum FRET (LS)',
|
|
'Transit time',
|
|
];
|
|
|
|
async detectFormat(filePath: string): Promise<'STANDARD' | 'FOB_FRET' | 'UNKNOWN'> {
|
|
try {
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
const lines = content.split('\n').filter(l => l.trim());
|
|
if (lines.length === 0) return 'UNKNOWN';
|
|
|
|
for (let i = 0; i < Math.min(2, lines.length); i++) {
|
|
const headers = this.parseCSVLine(lines[i]);
|
|
if (this.STANDARD_HEADERS.some(h => headers.includes(h))) return 'STANDARD';
|
|
if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) return 'FOB_FRET';
|
|
}
|
|
return 'UNKNOWN';
|
|
} catch {
|
|
return 'UNKNOWN';
|
|
}
|
|
}
|
|
|
|
async autoConvert(
|
|
inputPath: string,
|
|
companyName: string
|
|
): Promise<{ convertedPath: string; wasConverted: boolean; rowsConverted?: number }> {
|
|
const format = await this.detectFormat(inputPath);
|
|
this.logger.log(`Detected CSV format: ${format} for ${inputPath}`);
|
|
|
|
if (format === 'STANDARD') {
|
|
return { convertedPath: inputPath, wasConverted: false };
|
|
}
|
|
|
|
if (format === 'FOB_FRET') {
|
|
const result = await this.convertFobFretToStandard(inputPath, companyName);
|
|
return {
|
|
convertedPath: result.outputPath,
|
|
wasConverted: true,
|
|
rowsConverted: result.rowsConverted,
|
|
};
|
|
}
|
|
|
|
throw new Error(
|
|
'Unknown CSV format. Please provide a file matching the standard 33-column schema.'
|
|
);
|
|
}
|
|
|
|
async convertFobFretToStandard(
|
|
inputPath: string,
|
|
companyName: string
|
|
): Promise<{ outputPath: string; rowsConverted: number }> {
|
|
this.logger.log(`Converting legacy FOB FRET CSV: ${inputPath}`);
|
|
|
|
const content = await fs.readFile(inputPath, 'utf-8');
|
|
const lines = content.split('\n').filter(l => l.trim());
|
|
|
|
if (lines.length < 2) throw new Error('CSV file is empty or has no data rows');
|
|
|
|
// Find the header line
|
|
let headerLineIndex = 0;
|
|
for (let i = 0; i < Math.min(2, lines.length); i++) {
|
|
const headers = this.parseCSVLine(lines[i]);
|
|
if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) {
|
|
headerLineIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const headers = this.parseCSVLine(lines[headerLineIndex]);
|
|
|
|
const dataRows: Record<string, string>[] = [];
|
|
for (let i = headerLineIndex + 1; i < lines.length; i++) {
|
|
const values = this.parseCSVLine(lines[i]);
|
|
const row: Record<string, string> = {};
|
|
headers.forEach((header, idx) => (row[header] = values[idx] || ''));
|
|
if (row['Origine UN code'] && row['Destination UN code']) {
|
|
dataRows.push(row);
|
|
}
|
|
}
|
|
|
|
const convertedRows = dataRows.map(row => this.convertFobFretRow(row, companyName));
|
|
|
|
const outputLines: string[] = [this.STANDARD_HEADERS.join(',')];
|
|
convertedRows.forEach(row => {
|
|
const values = this.STANDARD_HEADERS.map(header => {
|
|
const value = row[header] ?? '';
|
|
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
|
|
return `"${value.replace(/"/g, '""')}"`;
|
|
}
|
|
return value;
|
|
});
|
|
outputLines.push(values.join(','));
|
|
});
|
|
|
|
const outputPath = path.isAbsolute(inputPath)
|
|
? inputPath.replace('.csv', '-converted.csv')
|
|
: path.resolve(process.cwd(), inputPath.replace('.csv', '-converted.csv'));
|
|
|
|
await fs.writeFile(outputPath, outputLines.join('\n'), 'utf-8');
|
|
this.logger.log(`Conversion complete: ${outputPath} (${convertedRows.length} rows)`);
|
|
|
|
return { outputPath, rowsConverted: convertedRows.length };
|
|
}
|
|
|
|
private convertFobFretRow(row: Record<string, string>, companyName: string): Record<string, any> {
|
|
const freightCurrency = row['Devise FRET'] || 'USD';
|
|
const freightRatePerCBM = parseFloat(row['Taux de FRET (UP)']) || 0;
|
|
const freightMinimum = parseFloat(row['Minimum FRET (LS)']) || 0;
|
|
const transitDays = parseInt(row['Transit time'], 10) || 0;
|
|
const fobCurrency = row['Devise FOB'] || 'EUR';
|
|
|
|
const validFrom = new Date().toISOString().split('T')[0];
|
|
const validUntil = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
|
|
|
const originCode = row['Origine UN code'] || '';
|
|
const destinationCode = row['Destination UN code'] || '';
|
|
|
|
return {
|
|
companyName,
|
|
companyEmail: row['Email'] || '',
|
|
originCFS: row['Origine CFS'] || originCode,
|
|
originCode,
|
|
portOfLoading: row['Port of Loading'] || originCode,
|
|
routing: row['Routing'] || 'Direct',
|
|
destinationCFS: row['Destination CFS'] || destinationCode,
|
|
destinationCode,
|
|
destinationCountry: row['Destination Country'] || '',
|
|
containerType: 'LCL',
|
|
freightCurrency,
|
|
freightRatePerCBM,
|
|
freightMinimum,
|
|
fobCurrency,
|
|
fobDocumentation: parseInt(row['Documentation (LS et Minimum)'], 10) || 0,
|
|
fobISPS: parseInt(row['ISPS (LS et Minimum)'], 10) || 0,
|
|
fobHandling: parseInt(row['Manutention'], 10) || 0,
|
|
fobHandlingUnit: row['Unité de manutention (UP;Tonne)'] || 'W',
|
|
fobHandlingMinimum: parseInt(row['Minimum manutention'], 10) || 0,
|
|
fobSolas: parseInt(row['Solas (LS et Minimum)'], 10) || 0,
|
|
fobCustoms: parseInt(row['Douane (LS et Minimum)'], 10) || 0,
|
|
fobAMS_ACI: parseFloat(row['AMS/ACI (LS et Minimum)']) || 0,
|
|
fobISF5: parseFloat(row['ISF5 (LS et Minimum)']) || 0,
|
|
fobDGAdmin: parseInt(row['Frais admin de dangereux (LS et Minimum)'], 10) || 0,
|
|
dgSurchargeCurrency: row['Devise surcharge DG'] || fobCurrency,
|
|
dgSurchargeRate: row['Taux surcharge DG'] || '0',
|
|
dgSurchargeUnit: row['Unité surcharge DG'] || 'LS',
|
|
dgSurchargeMin: row['Minimum surcharge DG'] || '0',
|
|
remarks: row['Remarques'] || '',
|
|
frequency: row['Frequence'] || 'Weekly',
|
|
transitDays,
|
|
validFrom,
|
|
validUntil,
|
|
};
|
|
}
|
|
|
|
private parseCSVLine(line: string): string[] {
|
|
const result: string[] = [];
|
|
let current = '';
|
|
let inQuotes = false;
|
|
|
|
for (const char of line) {
|
|
if (char === '"') {
|
|
inQuotes = !inQuotes;
|
|
} else if (char === ',' && !inQuotes) {
|
|
result.push(current.trim());
|
|
current = '';
|
|
} else {
|
|
current += char;
|
|
}
|
|
}
|
|
result.push(current.trim());
|
|
return result;
|
|
}
|
|
}
|