429 lines
12 KiB
TypeScript
429 lines
12 KiB
TypeScript
/**
|
|
* @ai-summary Catalog CSV import/export service
|
|
* @ai-context Handles bulk import with preview and export of vehicle catalog data
|
|
*/
|
|
|
|
import { Pool } from 'pg';
|
|
import { logger } from '../../../core/logging/logger';
|
|
import { PlatformCacheService } from '../../platform/domain/platform-cache.service';
|
|
|
|
export interface ImportRow {
|
|
year: number;
|
|
make: string;
|
|
model: string;
|
|
trim: string;
|
|
engineName: string | null;
|
|
transmissionType: string | null;
|
|
}
|
|
|
|
export interface ImportError {
|
|
row: number;
|
|
error: string;
|
|
}
|
|
|
|
export interface ImportPreviewResult {
|
|
previewId: string;
|
|
toCreate: ImportRow[];
|
|
toUpdate: ImportRow[];
|
|
errors: ImportError[];
|
|
valid: boolean;
|
|
}
|
|
|
|
export interface ImportApplyResult {
|
|
created: number;
|
|
updated: number;
|
|
errors: ImportError[];
|
|
}
|
|
|
|
export interface ExportRow {
|
|
year: number;
|
|
make: string;
|
|
model: string;
|
|
trim: string;
|
|
engineName: string | null;
|
|
transmissionType: string | null;
|
|
}
|
|
|
|
// In-memory preview cache (expires after 15 minutes)
|
|
const previewCache = new Map<string, { data: ImportPreviewResult; expiresAt: number }>();
|
|
|
|
export class CatalogImportService {
|
|
constructor(
|
|
private pool: Pool,
|
|
private platformCacheService?: PlatformCacheService
|
|
) {}
|
|
|
|
/**
|
|
* Parse CSV content and validate without applying changes
|
|
*/
|
|
async previewImport(csvContent: string): Promise<ImportPreviewResult> {
|
|
const previewId = uuidv4();
|
|
const toCreate: ImportRow[] = [];
|
|
const toUpdate: ImportRow[] = [];
|
|
const errors: ImportError[] = [];
|
|
|
|
const lines = csvContent.trim().split('\n');
|
|
if (lines.length < 2) {
|
|
return {
|
|
previewId,
|
|
toCreate,
|
|
toUpdate,
|
|
errors: [{ row: 0, error: 'CSV must have a header row and at least one data row' }],
|
|
valid: false,
|
|
};
|
|
}
|
|
|
|
// Parse header row
|
|
const header = this.parseCSVLine(lines[0]);
|
|
const headerLower = header.map(h => h.toLowerCase().trim());
|
|
|
|
// Validate required headers (no action column required - matches export format)
|
|
const requiredHeaders = ['year', 'make', 'model', 'trim'];
|
|
for (const required of requiredHeaders) {
|
|
if (!headerLower.includes(required)) {
|
|
return {
|
|
previewId,
|
|
toCreate,
|
|
toUpdate,
|
|
errors: [{ row: 1, error: `Missing required header: ${required}` }],
|
|
valid: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Find column indices
|
|
const colIndices = {
|
|
year: headerLower.indexOf('year'),
|
|
make: headerLower.indexOf('make'),
|
|
model: headerLower.indexOf('model'),
|
|
trim: headerLower.indexOf('trim'),
|
|
engineName: headerLower.indexOf('engine_name'),
|
|
transmissionType: headerLower.indexOf('transmission_type'),
|
|
};
|
|
|
|
// Parse data rows
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
if (!line) continue;
|
|
|
|
const values = this.parseCSVLine(line);
|
|
const rowNum = i + 1;
|
|
|
|
try {
|
|
const year = parseInt(values[colIndices.year], 10);
|
|
const make = values[colIndices.make]?.trim();
|
|
const model = values[colIndices.model]?.trim();
|
|
const trim = values[colIndices.trim]?.trim();
|
|
const engineName = colIndices.engineName >= 0 ? values[colIndices.engineName]?.trim() || null : null;
|
|
const transmissionType = colIndices.transmissionType >= 0 ? values[colIndices.transmissionType]?.trim() || null : null;
|
|
|
|
// Validate year
|
|
if (isNaN(year) || year < 1900 || year > 2100) {
|
|
errors.push({ row: rowNum, error: `Invalid year: ${values[colIndices.year]}` });
|
|
continue;
|
|
}
|
|
|
|
// Validate required fields
|
|
if (!make) {
|
|
errors.push({ row: rowNum, error: 'Make is required' });
|
|
continue;
|
|
}
|
|
if (!model) {
|
|
errors.push({ row: rowNum, error: 'Model is required' });
|
|
continue;
|
|
}
|
|
if (!trim) {
|
|
errors.push({ row: rowNum, error: 'Trim is required' });
|
|
continue;
|
|
}
|
|
|
|
const row: ImportRow = {
|
|
year,
|
|
make,
|
|
model,
|
|
trim,
|
|
engineName,
|
|
transmissionType,
|
|
};
|
|
|
|
// Check if record exists to determine create vs update (upsert logic)
|
|
const existsResult = await this.pool.query(
|
|
`SELECT id FROM vehicle_options
|
|
WHERE year = $1 AND make = $2 AND model = $3 AND trim = $4
|
|
LIMIT 1`,
|
|
[year, make, model, trim]
|
|
);
|
|
const exists = (existsResult.rowCount || 0) > 0;
|
|
|
|
// Auto-detect: if exists -> update, else -> create
|
|
if (exists) {
|
|
toUpdate.push(row);
|
|
} else {
|
|
toCreate.push(row);
|
|
}
|
|
} catch (error: any) {
|
|
errors.push({ row: rowNum, error: error.message || 'Parse error' });
|
|
}
|
|
}
|
|
|
|
const result: ImportPreviewResult = {
|
|
previewId,
|
|
toCreate,
|
|
toUpdate,
|
|
errors,
|
|
valid: errors.length === 0,
|
|
};
|
|
|
|
// Cache preview for 15 minutes
|
|
previewCache.set(previewId, {
|
|
data: result,
|
|
expiresAt: Date.now() + 15 * 60 * 1000,
|
|
});
|
|
|
|
// Clean up expired previews
|
|
this.cleanupExpiredPreviews();
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Apply a previously previewed import
|
|
*/
|
|
async applyImport(previewId: string, changedBy: string): Promise<ImportApplyResult> {
|
|
const cached = previewCache.get(previewId);
|
|
if (!cached || cached.expiresAt < Date.now()) {
|
|
throw new Error('Preview expired or not found. Please upload the file again.');
|
|
}
|
|
|
|
const preview = cached.data;
|
|
if (!preview.valid) {
|
|
throw new Error('Cannot apply import with validation errors');
|
|
}
|
|
|
|
const result: ImportApplyResult = {
|
|
created: 0,
|
|
updated: 0,
|
|
errors: [],
|
|
};
|
|
|
|
const client = await this.pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Process creates
|
|
for (const row of preview.toCreate) {
|
|
try {
|
|
// Get or create engine
|
|
let engineId: number | null = null;
|
|
if (row.engineName) {
|
|
const engineResult = await client.query(
|
|
`INSERT INTO engines (name, fuel_type)
|
|
VALUES ($1, 'Gas')
|
|
ON CONFLICT ((lower(name))) DO UPDATE SET name = EXCLUDED.name
|
|
RETURNING id`,
|
|
[row.engineName]
|
|
);
|
|
engineId = engineResult.rows[0].id;
|
|
}
|
|
|
|
// Get or create transmission
|
|
let transmissionId: number | null = null;
|
|
if (row.transmissionType) {
|
|
const transResult = await client.query(
|
|
`INSERT INTO transmissions (type)
|
|
VALUES ($1)
|
|
ON CONFLICT ((lower(type))) DO UPDATE SET type = EXCLUDED.type
|
|
RETURNING id`,
|
|
[row.transmissionType]
|
|
);
|
|
transmissionId = transResult.rows[0].id;
|
|
}
|
|
|
|
// Insert vehicle option
|
|
await client.query(
|
|
`INSERT INTO vehicle_options (year, make, model, trim, engine_id, transmission_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
[row.year, row.make, row.model, row.trim, engineId, transmissionId]
|
|
);
|
|
|
|
result.created++;
|
|
} catch (error: any) {
|
|
result.errors.push({ row: 0, error: `Failed to create ${row.year} ${row.make} ${row.model} ${row.trim}: ${error.message}` });
|
|
}
|
|
}
|
|
|
|
// Process updates
|
|
for (const row of preview.toUpdate) {
|
|
try {
|
|
// Get or create engine
|
|
let engineId: number | null = null;
|
|
if (row.engineName) {
|
|
const engineResult = await client.query(
|
|
`INSERT INTO engines (name, fuel_type)
|
|
VALUES ($1, 'Gas')
|
|
ON CONFLICT ((lower(name))) DO UPDATE SET name = EXCLUDED.name
|
|
RETURNING id`,
|
|
[row.engineName]
|
|
);
|
|
engineId = engineResult.rows[0].id;
|
|
}
|
|
|
|
// Get or create transmission
|
|
let transmissionId: number | null = null;
|
|
if (row.transmissionType) {
|
|
const transResult = await client.query(
|
|
`INSERT INTO transmissions (type)
|
|
VALUES ($1)
|
|
ON CONFLICT ((lower(type))) DO UPDATE SET type = EXCLUDED.type
|
|
RETURNING id`,
|
|
[row.transmissionType]
|
|
);
|
|
transmissionId = transResult.rows[0].id;
|
|
}
|
|
|
|
// Update vehicle option
|
|
await client.query(
|
|
`UPDATE vehicle_options
|
|
SET engine_id = $5, transmission_id = $6, updated_at = NOW()
|
|
WHERE year = $1 AND make = $2 AND model = $3 AND trim = $4`,
|
|
[row.year, row.make, row.model, row.trim, engineId, transmissionId]
|
|
);
|
|
|
|
result.updated++;
|
|
} catch (error: any) {
|
|
result.errors.push({ row: 0, error: `Failed to update ${row.year} ${row.make} ${row.model} ${row.trim}: ${error.message}` });
|
|
}
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Remove preview from cache
|
|
previewCache.delete(previewId);
|
|
|
|
// Invalidate vehicle data cache so dropdowns reflect new data
|
|
if (this.platformCacheService) {
|
|
await this.platformCacheService.invalidateVehicleData();
|
|
logger.debug('Vehicle data cache invalidated after import');
|
|
}
|
|
|
|
logger.info('Catalog import completed', {
|
|
previewId,
|
|
created: result.created,
|
|
updated: result.updated,
|
|
errors: result.errors.length,
|
|
changedBy,
|
|
});
|
|
|
|
return result;
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Export all vehicle options as CSV
|
|
*/
|
|
async exportCatalog(): Promise<string> {
|
|
const result = await this.pool.query(`
|
|
SELECT
|
|
vo.year,
|
|
vo.make,
|
|
vo.model,
|
|
vo.trim,
|
|
e.name AS engine_name,
|
|
t.type AS transmission_type
|
|
FROM vehicle_options vo
|
|
LEFT JOIN engines e ON vo.engine_id = e.id
|
|
LEFT JOIN transmissions t ON vo.transmission_id = t.id
|
|
ORDER BY vo.year DESC, vo.make ASC, vo.model ASC, vo.trim ASC
|
|
`);
|
|
|
|
// Build CSV
|
|
const header = 'year,make,model,trim,engine_name,transmission_type';
|
|
const rows = result.rows.map(row => {
|
|
return [
|
|
row.year,
|
|
this.escapeCSVField(row.make),
|
|
this.escapeCSVField(row.model),
|
|
this.escapeCSVField(row.trim),
|
|
this.escapeCSVField(row.engine_name || ''),
|
|
this.escapeCSVField(row.transmission_type || ''),
|
|
].join(',');
|
|
});
|
|
|
|
return [header, ...rows].join('\n');
|
|
}
|
|
|
|
/**
|
|
* Parse a single CSV line, handling quoted fields
|
|
*/
|
|
private parseCSVLine(line: string): string[] {
|
|
const result: string[] = [];
|
|
let current = '';
|
|
let inQuotes = false;
|
|
|
|
for (let i = 0; i < line.length; i++) {
|
|
const char = line[i];
|
|
const nextChar = line[i + 1];
|
|
|
|
if (inQuotes) {
|
|
if (char === '"' && nextChar === '"') {
|
|
current += '"';
|
|
i++; // Skip next quote
|
|
} else if (char === '"') {
|
|
inQuotes = false;
|
|
} else {
|
|
current += char;
|
|
}
|
|
} else {
|
|
if (char === '"') {
|
|
inQuotes = true;
|
|
} else if (char === ',') {
|
|
result.push(current);
|
|
current = '';
|
|
} else {
|
|
current += char;
|
|
}
|
|
}
|
|
}
|
|
|
|
result.push(current);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Escape a field for CSV output
|
|
*/
|
|
private escapeCSVField(value: string): string {
|
|
if (!value) return '';
|
|
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
|
return `"${value.replace(/"/g, '""')}"`;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Clean up expired preview cache entries
|
|
*/
|
|
private cleanupExpiredPreviews(): void {
|
|
const now = Date.now();
|
|
for (const [id, entry] of previewCache.entries()) {
|
|
if (entry.expiresAt < now) {
|
|
previewCache.delete(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Simple UUID generation without external dependency
|
|
function uuidv4(): string {
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
const r = Math.random() * 16 | 0;
|
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
return v.toString(16);
|
|
});
|
|
}
|