INSIGHT-MVP/packages/crm-service/src/import/import.service.ts
Thomas Reitz 63cb05d4d8 feat(crm): Phase 2.2-2.4 backend + contract files — vollständige CRM-Service Implementierung
- Phase 2.3 Forecast: probability-Feld in PipelineStage, GET /crm/deals/forecast Endpoint
- Phase 2.2 Import: ImportModule mit preview/execute/history Endpoints (CSV, XLSX, vCard)
- Phase 2.4 Enrichment: EnrichmentModule mit /enrich + /settings/integrations/north-data
- Contracts: ContractsModule mit CRUD + File-Upload Endpoints (Multer, max 25MB)
- Migrations: 20260312_contract_files, 20260312_phase23_forecast
- docker-compose.crm.yml: uploads Volume für Vertragsdokumente

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 22:06:58 +01:00

509 lines
16 KiB
TypeScript

import {
Injectable,
Logger,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { ImportEntityType } from './dto/import-preview.dto';
import {
ImportExecuteDto,
DuplicateStrategy,
FieldMappingDto,
} from './dto/import-execute.dto';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { v4 as uuidv4 } from 'uuid';
import csvParser from 'csv-parser';
import * as XLSX from 'xlsx';
import { Readable } from 'stream';
const MAX_ROWS = 5000;
const PREVIEW_ROWS = 10;
const TARGET_FIELDS: Record<ImportEntityType, string[]> = {
[ImportEntityType.CONTACT]: [
'firstName', 'lastName', 'email', 'phone', 'mobile',
'companyName', 'position', 'department', 'website',
'street', 'zip', 'city', 'state', 'country',
'notes', 'tags', 'source', 'linkedinUrl',
],
[ImportEntityType.COMPANY]: [
'name', 'email', 'phone', 'website', 'industry',
'street', 'zip', 'city', 'state', 'country',
'vatId', 'taxId', 'tradeRegisterNumber', 'registerCourt',
'notes', 'tags',
],
};
interface ParsedFile {
columns: string[];
rows: Record<string, string>[];
totalRows: number;
format: 'csv' | 'xlsx';
}
export interface ImportError {
row: number;
field: string;
value: string;
message: string;
}
@Injectable()
export class ImportService {
private readonly logger = new Logger(ImportService.name);
constructor(private readonly prisma: CrmPrismaService) {}
// --------------------------------------------------------
// Preview: Datei parsen und Vorschau zurueckgeben
// --------------------------------------------------------
async preview(
file: Express.Multer.File,
entityType: ImportEntityType,
delimiter?: string,
) {
const ext = path.extname(file.originalname).toLowerCase();
if (!['.csv', '.xlsx', '.xls'].includes(ext)) {
throw new BadRequestException(
'Nicht unterstuetztes Dateiformat. Erlaubt: .csv, .xlsx, .xls',
);
}
const parsed = ext === '.csv'
? await this.parseCsv(file.buffer, delimiter)
: this.parseExcel(file.buffer);
if (parsed.totalRows > MAX_ROWS) {
throw new BadRequestException(
`Datei enthaelt ${parsed.totalRows} Zeilen. Maximum: ${MAX_ROWS}`,
);
}
// Temp-Datei speichern (GDPR: wird nach Execute geloescht)
const importId = uuidv4();
const tmpPath = path.join(os.tmpdir(), `crm-import-${importId}${ext}`);
fs.writeFileSync(tmpPath, file.buffer);
return {
importId,
format: parsed.format,
columns: parsed.columns,
rows: parsed.rows.slice(0, PREVIEW_ROWS),
totalRows: parsed.totalRows,
availableTargetFields: TARGET_FIELDS[entityType],
};
}
// --------------------------------------------------------
// Execute: Import ausfuehren
// --------------------------------------------------------
async execute(tenantId: string, userId: string, dto: ImportExecuteDto) {
const tmpDir = os.tmpdir();
const possibleExts = ['.csv', '.xlsx', '.xls'];
let tmpPath: string | null = null;
let ext = '';
for (const e of possibleExts) {
const candidate = path.join(tmpDir, `crm-import-${dto.importId}${e}`);
if (fs.existsSync(candidate)) {
tmpPath = candidate;
ext = e;
break;
}
}
if (!tmpPath) {
throw new NotFoundException(
'Import-Datei nicht gefunden. Bitte erneut hochladen (Preview abgelaufen).',
);
}
try {
const buffer = fs.readFileSync(tmpPath);
const parsed = ext === '.csv'
? await this.parseCsv(buffer)
: this.parseExcel(buffer);
this.validateMapping(dto.mapping, dto.entityType);
const result = dto.entityType === ImportEntityType.CONTACT
? await this.importContacts(tenantId, userId, parsed.rows, dto.mapping, dto.duplicateStrategy ?? DuplicateStrategy.SKIP)
: await this.importCompanies(tenantId, userId, parsed.rows, dto.mapping, dto.duplicateStrategy ?? DuplicateStrategy.SKIP);
return result;
} finally {
// GDPR: Temp-Datei immer loeschen
try {
fs.unlinkSync(tmpPath);
this.logger.debug(`Temp-Datei geloescht: ${tmpPath}`);
} catch {
this.logger.warn(`Temp-Datei konnte nicht geloescht werden: ${tmpPath}`);
}
}
}
// --------------------------------------------------------
// Privat: CSV parsen
// --------------------------------------------------------
private parseCsv(buffer: Buffer, delimiter?: string): Promise<ParsedFile> {
return new Promise((resolve, reject) => {
const rows: Record<string, string>[] = [];
let columns: string[] = [];
const stream = Readable.from(buffer);
stream
.pipe(csvParser({
separator: delimiter || undefined,
mapHeaders: ({ header }: { header: string }) => header.trim(),
}))
.on('headers', (headers: string[]) => {
columns = headers;
})
.on('data', (row: Record<string, string>) => {
if (rows.length < MAX_ROWS) {
rows.push(row);
}
})
.on('end', () => {
resolve({ columns, rows, totalRows: rows.length, format: 'csv' });
})
.on('error', (err: Error) => {
reject(new BadRequestException(`CSV-Parse-Fehler: ${err.message}`));
});
});
}
// --------------------------------------------------------
// Privat: Excel parsen
// --------------------------------------------------------
private parseExcel(buffer: Buffer): ParsedFile {
const workbook = XLSX.read(buffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
throw new BadRequestException('Excel-Datei enthaelt keine Arbeitsblaetter');
}
const sheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json<Record<string, string>>(sheet, {
defval: '',
raw: false,
});
if (jsonData.length === 0) {
throw new BadRequestException('Excel-Datei enthaelt keine Daten');
}
const columns = Object.keys(jsonData[0]);
const rows = jsonData.slice(0, MAX_ROWS);
return { columns, rows, totalRows: jsonData.length, format: 'xlsx' };
}
// --------------------------------------------------------
// Privat: Mapping validieren
// --------------------------------------------------------
private validateMapping(
mapping: FieldMappingDto[],
entityType: ImportEntityType,
): void {
const allowed = TARGET_FIELDS[entityType];
for (const m of mapping) {
if (!allowed.includes(m.targetField)) {
throw new BadRequestException(
`Ungueltiges Zielfeld "${m.targetField}" fuer ${entityType}. Erlaubt: ${allowed.join(', ')}`,
);
}
}
}
// --------------------------------------------------------
// Privat: Kontakte importieren
// --------------------------------------------------------
private async importContacts(
tenantId: string,
userId: string,
rows: Record<string, string>[],
mapping: FieldMappingDto[],
duplicateStrategy: DuplicateStrategy,
) {
let created = 0;
let updated = 0;
let skipped = 0;
const errors: ImportError[] = [];
for (let i = 0; i < rows.length; i++) {
const rowNum = i + 2; // +2 weil Header = Zeile 1
try {
const mapped = this.mapRow(rows[i], mapping);
const email = (mapped['email'] ?? '').trim().toLowerCase();
// Duplikat pruefen
let existingId: string | null = null;
if (email) {
const existing = await this.prisma.contact.findFirst({
where: {
tenantId,
email: { equals: email, mode: 'insensitive' },
},
});
existingId = existing?.id ?? null;
}
if (existingId) {
switch (duplicateStrategy) {
case DuplicateStrategy.SKIP:
skipped++;
continue;
case DuplicateStrategy.UPDATE:
await this.prisma.contact.update({
where: { id: existingId },
data: {
...this.buildContactData(mapped),
updatedBy: userId,
},
});
updated++;
continue;
case DuplicateStrategy.MARK:
mapped['_isDuplicate'] = 'true';
break;
}
}
const tags = mapped['_isDuplicate'] === 'true' ? ['DUPLIKAT'] : [];
if (mapped['tags']) {
tags.push(...mapped['tags'].split(',').map((t: string) => t.trim()));
}
await this.prisma.contact.create({
data: {
tenantId,
createdBy: userId,
firstName: mapped['firstName'] ?? null,
lastName: mapped['lastName'] ?? null,
email: mapped['email'] ?? null,
phone: mapped['phone'] ?? null,
mobile: mapped['mobile'] ?? null,
companyName: mapped['companyName'] ?? null,
position: mapped['position'] ?? null,
department: mapped['department'] ?? null,
website: mapped['website'] ?? null,
linkedinUrl: mapped['linkedinUrl'] ?? null,
street: mapped['street'] ?? null,
zip: mapped['zip'] ?? null,
city: mapped['city'] ?? null,
state: mapped['state'] ?? null,
country: mapped['country'] ?? 'DE',
notes: mapped['notes'] ?? null,
tags,
source: 'IMPORT',
owners: {
create: { tenantId, userId, role: 'OWNER' },
},
},
});
created++;
} catch (err) {
errors.push({
row: rowNum,
field: '',
value: '',
message: err instanceof Error ? err.message : 'Unbekannter Fehler',
});
}
}
return {
created,
updated,
skipped,
errors: errors.length,
totalProcessed: rows.length,
errorDetails: errors.slice(0, 50),
};
}
// --------------------------------------------------------
// Privat: Unternehmen importieren
// --------------------------------------------------------
private async importCompanies(
tenantId: string,
userId: string,
rows: Record<string, string>[],
mapping: FieldMappingDto[],
duplicateStrategy: DuplicateStrategy,
) {
let created = 0;
let updated = 0;
let skipped = 0;
const errors: ImportError[] = [];
for (let i = 0; i < rows.length; i++) {
const rowNum = i + 2;
try {
const mapped = this.mapRow(rows[i], mapping);
const email = (mapped['email'] ?? '').trim().toLowerCase();
let existingId: string | null = null;
if (email) {
const existing = await this.prisma.company.findFirst({
where: {
tenantId,
email: { equals: email, mode: 'insensitive' },
},
});
existingId = existing?.id ?? null;
}
if (existingId) {
switch (duplicateStrategy) {
case DuplicateStrategy.SKIP:
skipped++;
continue;
case DuplicateStrategy.UPDATE:
await this.prisma.company.update({
where: { id: existingId },
data: {
...this.buildCompanyData(mapped),
updatedBy: userId,
},
});
updated++;
continue;
case DuplicateStrategy.MARK:
mapped['_isDuplicate'] = 'true';
break;
}
}
const name = mapped['name'] ?? '';
if (!name) {
errors.push({
row: rowNum,
field: 'name',
value: '',
message: 'Unternehmensname ist Pflichtfeld',
});
continue;
}
const tags = mapped['_isDuplicate'] === 'true' ? ['DUPLIKAT'] : [];
if (mapped['tags']) {
tags.push(...mapped['tags'].split(',').map((t: string) => t.trim()));
}
await this.prisma.company.create({
data: {
tenantId,
createdBy: userId,
name,
email: mapped['email'] ?? null,
phone: mapped['phone'] ?? null,
website: mapped['website'] ?? null,
industry: mapped['industry'] ?? null,
vatId: mapped['vatId'] ?? null,
taxId: mapped['taxId'] ?? null,
tradeRegisterNumber: mapped['tradeRegisterNumber'] ?? null,
registerCourt: mapped['registerCourt'] ?? null,
street: mapped['street'] ?? null,
zip: mapped['zip'] ?? null,
city: mapped['city'] ?? null,
state: mapped['state'] ?? null,
country: mapped['country'] ?? 'DE',
notes: mapped['notes'] ?? null,
tags,
owners: {
create: { tenantId, userId, role: 'OWNER' },
},
},
});
created++;
} catch (err) {
errors.push({
row: rowNum,
field: '',
value: '',
message: err instanceof Error ? err.message : 'Unbekannter Fehler',
});
}
}
return {
created,
updated,
skipped,
errors: errors.length,
totalProcessed: rows.length,
errorDetails: errors.slice(0, 50),
};
}
// --------------------------------------------------------
// Helfer
// --------------------------------------------------------
private mapRow(
row: Record<string, string>,
mapping: FieldMappingDto[],
): Record<string, string> {
const result: Record<string, string> = {};
for (const m of mapping) {
const value = row[m.sourceColumn];
if (value !== undefined && value !== '') {
result[m.targetField] = value;
}
}
return result;
}
private buildContactData(mapped: Record<string, string>) {
return {
...(mapped['firstName'] && { firstName: mapped['firstName'] }),
...(mapped['lastName'] && { lastName: mapped['lastName'] }),
...(mapped['email'] && { email: mapped['email'] }),
...(mapped['phone'] && { phone: mapped['phone'] }),
...(mapped['mobile'] && { mobile: mapped['mobile'] }),
...(mapped['companyName'] && { companyName: mapped['companyName'] }),
...(mapped['position'] && { position: mapped['position'] }),
...(mapped['department'] && { department: mapped['department'] }),
...(mapped['website'] && { website: mapped['website'] }),
...(mapped['linkedinUrl'] && { linkedinUrl: mapped['linkedinUrl'] }),
...(mapped['street'] && { street: mapped['street'] }),
...(mapped['zip'] && { zip: mapped['zip'] }),
...(mapped['city'] && { city: mapped['city'] }),
...(mapped['state'] && { state: mapped['state'] }),
...(mapped['country'] && { country: mapped['country'] }),
...(mapped['notes'] && { notes: mapped['notes'] }),
};
}
private buildCompanyData(mapped: Record<string, string>) {
return {
...(mapped['name'] && { name: mapped['name'] }),
...(mapped['email'] && { email: mapped['email'] }),
...(mapped['phone'] && { phone: mapped['phone'] }),
...(mapped['website'] && { website: mapped['website'] }),
...(mapped['industry'] && { industry: mapped['industry'] }),
...(mapped['vatId'] && { vatId: mapped['vatId'] }),
...(mapped['taxId'] && { taxId: mapped['taxId'] }),
...(mapped['tradeRegisterNumber'] && { tradeRegisterNumber: mapped['tradeRegisterNumber'] }),
...(mapped['registerCourt'] && { registerCourt: mapped['registerCourt'] }),
...(mapped['street'] && { street: mapped['street'] }),
...(mapped['zip'] && { zip: mapped['zip'] }),
...(mapped['city'] && { city: mapped['city'] }),
...(mapped['state'] && { state: mapped['state'] }),
...(mapped['country'] && { country: mapped['country'] }),
...(mapped['notes'] && { notes: mapped['notes'] }),
};
}
}