INSIGHT-MVP/packages/crm-service/src/enrichment/enrichment.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

307 lines
9.8 KiB
TypeScript

import {
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import { CrmPrismaService } from '../prisma/crm-prisma.service';
import { RedisService } from '../redis/redis.service';
import {
EnrichmentResponse,
EnrichmentSuggestion,
} from './dto/enrich-response.dto';
import {
NorthDataSettings,
UpdateNorthDataSettingsDto,
} from './dto/enrichment-settings.dto';
const NORTH_DATA_KEY = (tenantId: string) =>
`crm:${tenantId}:integrations:north_data`;
@Injectable()
export class EnrichmentService {
private readonly logger = new Logger(EnrichmentService.name);
constructor(
private readonly prisma: CrmPrismaService,
private readonly redis: RedisService,
private readonly config: ConfigService,
private readonly httpService: HttpService,
) {}
// --------------------------------------------------------
// Unternehmen anreichern (Suggestion-Only)
// --------------------------------------------------------
async enrichCompany(
tenantId: string,
companyId: string,
): Promise<EnrichmentResponse> {
const company = await this.prisma.company.findFirst({
where: { id: companyId, tenantId },
});
if (!company) {
throw new NotFoundException('Unternehmen nicht gefunden');
}
const warnings: string[] = [];
const sources: string[] = [];
const suggestions: Record<string, EnrichmentSuggestion> = {};
const northDataSettings = await this.getNorthDataSettings(tenantId);
// Parallele Abfragen
const [registerResult, northDataResult] = await Promise.allSettled([
this.queryUnternehmensregister(company.name, company.city ?? undefined),
northDataSettings.enabled && northDataSettings.apiKey
? this.queryNorthData(company.name, northDataSettings.apiKey)
: Promise.resolve(null),
]);
// Unternehmensregister.de Ergebnisse verarbeiten
if (registerResult.status === 'fulfilled' && registerResult.value) {
sources.push('unternehmensregister.de');
const data = registerResult.value;
this.addSuggestion(suggestions, 'tradeRegisterNumber', company.tradeRegisterNumber, data.registerNumber, 'unternehmensregister.de');
this.addSuggestion(suggestions, 'registerCourt', company.registerCourt, data.registerCourt, 'unternehmensregister.de');
this.addSuggestion(suggestions, 'street', company.street, data.street, 'unternehmensregister.de');
this.addSuggestion(suggestions, 'zip', company.zip, data.zip, 'unternehmensregister.de');
this.addSuggestion(suggestions, 'city', company.city, data.city, 'unternehmensregister.de');
} else if (registerResult.status === 'rejected') {
warnings.push(`Unternehmensregister: ${String(registerResult.reason)}`);
}
// North Data Ergebnisse verarbeiten
if (northDataResult.status === 'fulfilled' && northDataResult.value) {
sources.push('northdata.de');
const data = northDataResult.value;
this.addSuggestion(suggestions, 'name', company.name, data.name, 'northdata.de');
this.addSuggestion(suggestions, 'vatId', company.vatId, data.vatId, 'northdata.de');
this.addSuggestion(suggestions, 'website', company.website, data.website, 'northdata.de');
this.addSuggestion(suggestions, 'phone', company.phone, data.phone, 'northdata.de');
this.addSuggestion(suggestions, 'street', company.street, data.street, 'northdata.de');
this.addSuggestion(suggestions, 'zip', company.zip, data.zip, 'northdata.de');
this.addSuggestion(suggestions, 'city', company.city, data.city, 'northdata.de');
this.addSuggestion(suggestions, 'industry', company.industry, data.industry, 'northdata.de');
} else if (northDataResult.status === 'rejected' && northDataSettings.enabled) {
warnings.push(`North Data: ${String(northDataResult.reason)}`);
} else if (!northDataSettings.enabled) {
warnings.push('North Data Integration ist nicht aktiviert.');
}
if (sources.length === 0) {
warnings.push('Keine Datenquelle konnte abgefragt werden.');
}
return {
companyId,
companyName: company.name,
sources,
suggestions,
enrichedAt: new Date().toISOString(),
warnings,
};
}
// --------------------------------------------------------
// Unternehmensregister.de Abfrage (kostenfrei)
// --------------------------------------------------------
private async queryUnternehmensregister(
companyName: string,
city?: string,
): Promise<{
registerNumber: string | null;
registerCourt: string | null;
street: string | null;
zip: string | null;
city: string | null;
} | null> {
try {
const searchUrl = 'https://www.unternehmensregister.de/trxweb/api/search';
const response = await firstValueFrom(
this.httpService.get(searchUrl, {
params: {
keyword: companyName,
...(city ? { location: city } : {}),
},
timeout: 10000,
}),
);
const data = response.data;
if (data && typeof data === 'object') {
return {
registerNumber: data.registerNumber ?? null,
registerCourt: data.registerCourt ?? null,
street: data.street ?? null,
zip: data.zip ?? null,
city: data.city ?? null,
};
}
return null;
} catch (err) {
this.logger.warn(
`Unternehmensregister-Abfrage fehlgeschlagen fuer "${companyName}": ${err instanceof Error ? err.message : String(err)}`,
);
throw new Error('Unternehmensregister nicht erreichbar');
}
}
// --------------------------------------------------------
// North Data API Abfrage (kostenpflichtig)
// --------------------------------------------------------
private async queryNorthData(
companyName: string,
apiKey: string,
): Promise<{
name: string | null;
vatId: string | null;
website: string | null;
phone: string | null;
street: string | null;
zip: string | null;
city: string | null;
industry: string | null;
} | null> {
try {
const baseUrl = this.config.get<string>(
'NORTH_DATA_API_URL',
'https://www.northdata.de/_api',
);
const response = await firstValueFrom(
this.httpService.get(`${baseUrl}/company/v1/company`, {
params: {
name: companyName,
address: 'DE',
},
headers: {
'X-Api-Key': apiKey,
},
timeout: 15000,
}),
);
const data = response.data;
if (data && typeof data === 'object') {
return {
name: data.name?.current ?? null,
vatId: data.vatId ?? null,
website: data.website ?? null,
phone: data.phone ?? null,
street: data.address?.street ?? null,
zip: data.address?.postalCode ?? null,
city: data.address?.city ?? null,
industry: data.industry?.description ?? null,
};
}
return null;
} catch (err) {
this.logger.warn(
`North Data Abfrage fehlgeschlagen fuer "${companyName}": ${err instanceof Error ? err.message : String(err)}`,
);
throw new Error('North Data API nicht erreichbar');
}
}
// --------------------------------------------------------
// Helfer: Suggestion hinzufuegen (nur wenn suggested != current)
// --------------------------------------------------------
private addSuggestion(
suggestions: Record<string, EnrichmentSuggestion>,
field: string,
current: string | null | undefined,
suggested: string | null | undefined,
source: string,
): void {
if (!suggested) return;
const currentVal = current ?? null;
const suggestedVal = suggested;
// Nur hinzufuegen wenn current leer oder verschieden
if (currentVal !== suggestedVal) {
// Erste Quelle gewinnt bei Duplikaten
if (!suggestions[field]) {
suggestions[field] = {
current: currentVal,
suggested: suggestedVal,
source,
};
}
}
}
// --------------------------------------------------------
// North Data Settings (Redis)
// --------------------------------------------------------
async getNorthDataSettings(tenantId: string): Promise<NorthDataSettings> {
const key = NORTH_DATA_KEY(tenantId);
const raw = await this.redis.get(key);
if (!raw) {
const envKey = this.config.get<string>('NORTH_DATA_API_KEY');
return {
apiKey: envKey ?? null,
enabled: !!envKey,
};
}
return JSON.parse(raw) as NorthDataSettings;
}
async updateNorthDataSettings(
tenantId: string,
dto: UpdateNorthDataSettingsDto,
): Promise<NorthDataSettings> {
const current = await this.getNorthDataSettings(tenantId);
const updated: NorthDataSettings = {
apiKey: dto.apiKey !== undefined ? dto.apiKey : current.apiKey,
enabled: dto.enabled !== undefined ? dto.enabled : current.enabled,
};
const key = NORTH_DATA_KEY(tenantId);
await this.redis.set(key, JSON.stringify(updated));
return updated;
}
// --------------------------------------------------------
// Health Check
// --------------------------------------------------------
async isHealthy(): Promise<'up' | 'down' | 'unconfigured'> {
const envKey = this.config.get<string>('NORTH_DATA_API_KEY');
if (!envKey) return 'unconfigured';
try {
const baseUrl = this.config.get<string>(
'NORTH_DATA_API_URL',
'https://www.northdata.de/_api',
);
await firstValueFrom(
this.httpService.get(`${baseUrl}/health`, {
headers: { 'X-Api-Key': envKey },
timeout: 5000,
}),
);
return 'up';
} catch {
return 'down';
}
}
}