mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 08:46:39 +02:00
- 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>
509 lines
16 KiB
TypeScript
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'] }),
|
|
};
|
|
}
|
|
}
|