feat(leads): CSV-Import für Leads
"CSV importieren"-Dialog auf /leads. Serverseitiger lead.importCsv: eigener CSV-Parser (Quotes/Kommata/Zeilenumbrüche, mit Tests), header-basiertes Mapping (umlaut-tolerant). Pro Zeile entsteht ein Lead (Adresse → standorte) + primärer Kontakt; Notizen werden als erster Austausch-Log-Eintrag gespeichert. Dedup über Firmenname. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
864a1cef6a
commit
9f3ee13094
5 files changed
+329
-1
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCsv, normalizeHeader } from './csv.js';
|
||||
|
||||
describe('parseCsv', () => {
|
||||
it('parst einfache Zeilen', () => {
|
||||
expect(parseCsv('a,b,c\n1,2,3')).toEqual([
|
||||
['a', 'b', 'c'],
|
||||
['1', '2', '3'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignoriert einen abschließenden Zeilenumbruch', () => {
|
||||
expect(parseCsv('a,b\n1,2\n')).toEqual([
|
||||
['a', 'b'],
|
||||
['1', '2'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('behandelt \\r\\n', () => {
|
||||
expect(parseCsv('a,b\r\n1,2')).toEqual([
|
||||
['a', 'b'],
|
||||
['1', '2'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('respektiert Anführungszeichen mit Komma und Quote-Escape', () => {
|
||||
expect(parseCsv('a,b\n"x, y","sagt ""hi"""')).toEqual([
|
||||
['a', 'b'],
|
||||
['x, y', 'sagt "hi"'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('erlaubt Zeilenumbruch innerhalb von Quotes', () => {
|
||||
expect(parseCsv('a,b\n"erste\nzweite",2')).toEqual([
|
||||
['a', 'b'],
|
||||
['erste\nzweite', '2'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('behält leere Felder', () => {
|
||||
expect(parseCsv('a,b,c\n1,,3')).toEqual([
|
||||
['a', 'b', 'c'],
|
||||
['1', '', '3'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeHeader', () => {
|
||||
it('normalisiert Umlaute und Sonderzeichen', () => {
|
||||
expect(normalizeHeader('E-Mail')).toBe('email');
|
||||
expect(normalizeHeader('Straße')).toBe('strasse');
|
||||
expect(normalizeHeader(' Quelle ')).toBe('quelle');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// Minimaler, robuster CSV-Parser (RFC-4180-nah): unterstützt Anführungszeichen,
|
||||
// eingebettete Kommata/Zeilenumbrüche und doppelte Quotes ("") als Escape.
|
||||
|
||||
export function parseCsv(input: string): string[][] {
|
||||
const text = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (inQuotes) {
|
||||
if (c === '"') {
|
||||
if (text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
field += c;
|
||||
}
|
||||
} else if (c === '"') {
|
||||
inQuotes = true;
|
||||
} else if (c === ',') {
|
||||
row.push(field);
|
||||
field = '';
|
||||
} else if (c === '\n') {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
field = '';
|
||||
} else {
|
||||
field += c;
|
||||
}
|
||||
}
|
||||
// Letztes Feld / letzte Zeile (falls Datei nicht mit \n endet).
|
||||
if (field !== '' || row.length > 0) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Header-Zelle auf einen vergleichbaren Schlüssel normalisieren
|
||||
// ("E-Mail" → "email", "Straße" → "strasse").
|
||||
export function normalizeHeader(h: string): string {
|
||||
return h
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { lead, leadContact, leadTouchpoint, deal, area, user } from '@cockpit/db
|
||||
import type { Db } from '@cockpit/db';
|
||||
import { LeadSchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
import { parseCsv, normalizeHeader } from '../lib/csv.js';
|
||||
|
||||
const SALES_AREA_SLUG = 'vertrieb';
|
||||
|
||||
@@ -189,4 +190,117 @@ export const leadRouter = router({
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
// CSV-Import: erzeugt je Zeile einen Lead + primären Kontakt; Adresse →
|
||||
// standorte, Notizen → erster Austausch-Log-Eintrag. Dedup über Firmenname.
|
||||
importCsv: protectedProcedure
|
||||
.input(LeadSchemas.importLeadsCsvInput)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const areaId = await getSalesAreaId(ctx.db);
|
||||
const rows = parseCsv(input.csv);
|
||||
const summary = { created: 0, skippedEmpty: 0, skippedDuplicate: 0, contacts: 0, touchpoints: 0 };
|
||||
if (rows.length < 2 || !rows[0]) return summary;
|
||||
|
||||
const header = rows[0].map(normalizeHeader);
|
||||
const col = (key: string) => header.indexOf(key);
|
||||
const cells = {
|
||||
quelle: col('quelle'),
|
||||
vorname: col('vorname'),
|
||||
nachname: col('nachname'),
|
||||
position: col('position'),
|
||||
firma: col('firma'),
|
||||
strasse: col('strasse'),
|
||||
plz: col('plz'),
|
||||
ort: col('ort'),
|
||||
telefon: col('telefon'),
|
||||
mobil: col('mobil'),
|
||||
email: col('email'),
|
||||
website: col('website'),
|
||||
branche: col('branche'),
|
||||
notizen: col('notizen'),
|
||||
};
|
||||
const get = (row: string[], i: number) => (i >= 0 && i < row.length ? (row[i] ?? '').trim() : '');
|
||||
|
||||
const existing = await ctx.db.select({ firma: lead.firma }).from(lead);
|
||||
const seen = new Set(existing.map((e) => e.firma.trim().toLowerCase()));
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
for (let r = 1; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
if (!row || row.every((c) => c.trim() === '')) continue; // Leerzeile
|
||||
|
||||
const vorname = get(row, cells.vorname);
|
||||
const nachname = get(row, cells.nachname);
|
||||
const personName = `${vorname} ${nachname}`.trim();
|
||||
const firma = get(row, cells.firma) || personName;
|
||||
if (!firma) {
|
||||
summary.skippedEmpty++;
|
||||
continue;
|
||||
}
|
||||
const key = firma.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
summary.skippedDuplicate++;
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
|
||||
const standorte = [
|
||||
get(row, cells.strasse),
|
||||
[get(row, cells.plz), get(row, cells.ort)].filter(Boolean).join(' '),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
const [createdLead] = await tx
|
||||
.insert(lead)
|
||||
.values({
|
||||
areaId,
|
||||
firma,
|
||||
status: 'cold',
|
||||
score: 0,
|
||||
branche: get(row, cells.branche) || null,
|
||||
website: get(row, cells.website) || null,
|
||||
quelle: get(row, cells.quelle) || null,
|
||||
standorte: standorte || null,
|
||||
createdBy: ctx.user.id,
|
||||
})
|
||||
.returning();
|
||||
if (!createdLead) throw new Error('Lead konnte nicht angelegt werden.');
|
||||
summary.created++;
|
||||
|
||||
const email = get(row, cells.email);
|
||||
const telefon = get(row, cells.telefon);
|
||||
const mobil = get(row, cells.mobil);
|
||||
const position = get(row, cells.position);
|
||||
if (personName || email || telefon || mobil) {
|
||||
const tel = [telefon, mobil ? `Mobil: ${mobil}` : ''].filter(Boolean).join(' · ');
|
||||
await tx.insert(leadContact).values({
|
||||
leadId: createdLead.id,
|
||||
name: personName || firma,
|
||||
rolle: position || null,
|
||||
email: email || null,
|
||||
telefon: tel || null,
|
||||
isPrimary: true,
|
||||
order: 0,
|
||||
});
|
||||
summary.contacts++;
|
||||
}
|
||||
|
||||
const notizen = get(row, cells.notizen);
|
||||
if (notizen) {
|
||||
await tx.insert(leadTouchpoint).values({
|
||||
leadId: createdLead.id,
|
||||
datum: today,
|
||||
kanal: 'sonstiges',
|
||||
bearbeiterUserId: ctx.user.id,
|
||||
inhalt: notizen.slice(0, 2000),
|
||||
});
|
||||
summary.touchpoints++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return summary;
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user