import "server-only"; import Papa from "papaparse"; import ExcelJS from "exceljs"; import { IMPORT_ARTEN, spaltenZuordnen, wertLesen, type ImportArt, type ImportFeld, } from "@/lib/stammdaten/import-felder"; /** * Datei-Import für Stammdaten. * * Ablauf in drei Schritten, und der mittlere ist der wichtige: * * 1. Datei einlesen — CSV oder Excel, Spaltenüberschriften erkennen * 2. Zuordnung und Vorschau — was landet wo, und was stimmt daran nicht * 3. Übernehmen — erst hier wird geschrieben * * Zwischen 2 und 3 sitzt ein Mensch. Ohne diese Vorschau würde eine Wehr dem * Ergebnis nicht trauen, und zu Recht: eine falsch zugeordnete Spalte * verteilt Seriennummern auf Inventarnummern, und das merkt man erst Wochen * später am Regal. */ /** Höchstwerte, damit ein versehentlich gewähltes 200-MB-Archiv nicht den * Server beschäftigt. */ export const MAX_DATEIGROESSE = 10 * 1024 * 1024; export const MAX_ZEILEN = 5_000; export interface GeleseneTabelle { spalten: string[]; zeilen: Record[]; /** Wie viele Zeilen die Datei hatte, bevor gekürzt wurde. */ zeilenGesamt: number; gekuerzt: boolean; } export interface ZellFehler { zeile: number; spalte: string; feld: string; meldung: string; } export interface VorschauZeile { /** Zeilennummer in der Datei, 1-basiert ohne Überschrift. */ nummer: number; werte: Record; rohwerte: Record; fehler: ZellFehler[]; /** true, wenn diese Zeile übernommen werden kann. */ uebernehmbar: boolean; } export interface ImportVorschau { art: ImportArt; felder: ImportFeld[]; spalten: string[]; zuordnung: Record; zeilen: VorschauZeile[]; zeilenGesamt: number; gekuerzt: boolean; /** Pflichtfelder, denen keine Spalte zugeordnet ist. */ fehlendePflichtfelder: string[]; anzahlUebernehmbar: number; anzahlFehlerhaft: number; } // ----------------------------------------------------------------------------- // Schritt 1: Datei einlesen // ----------------------------------------------------------------------------- /** Erkennt das Format an der Endung, ersatzweise am Inhalt. */ export function formatErkennen(dateiname: string): "csv" | "xlsx" | null { const endung = dateiname.toLowerCase().split(".").pop(); if (endung === "csv" || endung === "txt") return "csv"; if (endung === "xlsx" || endung === "xlsm") return "xlsx"; return null; } /** * CSV einlesen. Das Trennzeichen wird erraten — deutsche Tabellen kommen * meistens mit Semikolon, weil Excel das im deutschen Sprachraum so schreibt. */ export function csvLesen(inhalt: string): GeleseneTabelle { const ergebnis = Papa.parse>(inhalt, { header: true, skipEmptyLines: "greedy", delimiter: "", delimitersToGuess: [";", ",", "\t", "|"], transformHeader: (kopf) => kopf.trim(), }); const alleZeilen = (ergebnis.data ?? []).filter((zeile) => Object.values(zeile).some((wert) => (wert ?? "").toString().trim() !== ""), ); const spalten = (ergebnis.meta.fields ?? []).filter((feld) => feld.trim() !== ""); return { spalten, zeilen: alleZeilen.slice(0, MAX_ZEILEN).map(zeileNormalisieren), zeilenGesamt: alleZeilen.length, gekuerzt: alleZeilen.length > MAX_ZEILEN, }; } /** Excel einlesen. Es wird immer das erste Arbeitsblatt genommen. */ export async function excelLesen(daten: Buffer): Promise { const mappe = new ExcelJS.Workbook(); await mappe.xlsx.load(daten as unknown as ArrayBuffer); const blatt = mappe.worksheets[0]; if (!blatt) { return { spalten: [], zeilen: [], zeilenGesamt: 0, gekuerzt: false }; } const kopfzeile = blatt.getRow(1); const spalten: string[] = []; kopfzeile.eachCell({ includeEmpty: false }, (zelle, nummer) => { const text = zellenText(zelle.value).trim(); spalten[nummer - 1] = text || `Spalte ${nummer}`; }); const zeilen: Record[] = []; let gesamt = 0; blatt.eachRow({ includeEmpty: false }, (zeile, nummer) => { if (nummer === 1) return; const datensatz: Record = {}; let hatInhalt = false; spalten.forEach((spalte, index) => { if (!spalte) return; const wert = zellenText(zeile.getCell(index + 1).value).trim(); datensatz[spalte] = wert; if (wert) hatInhalt = true; }); if (!hatInhalt) return; gesamt += 1; if (zeilen.length < MAX_ZEILEN) zeilen.push(datensatz); }); return { spalten: spalten.filter(Boolean), zeilen, zeilenGesamt: gesamt, gekuerzt: gesamt > MAX_ZEILEN, }; } /** Excel liefert je nach Zelle Zahlen, Daten, Formelergebnisse oder Rich Text. */ function zellenText(wert: ExcelJS.CellValue): string { if (wert === null || wert === undefined) return ""; if (wert instanceof Date) { const tag = String(wert.getUTCDate()).padStart(2, "0"); const monat = String(wert.getUTCMonth() + 1).padStart(2, "0"); return `${tag}.${monat}.${wert.getUTCFullYear()}`; } if (typeof wert === "object") { if ("text" in wert && typeof wert.text === "string") return wert.text; if ("result" in wert) return zellenText(wert.result as ExcelJS.CellValue); if ("richText" in wert && Array.isArray(wert.richText)) { return wert.richText.map((teil) => teil.text).join(""); } if ("hyperlink" in wert && typeof wert.text === "string") return wert.text; return ""; } return String(wert); } function zeileNormalisieren(zeile: Record): Record { const ergebnis: Record = {}; for (const [schluessel, wert] of Object.entries(zeile)) { ergebnis[schluessel.trim()] = (wert ?? "").toString().trim(); } return ergebnis; } /** Liest eine hochgeladene Datei, unabhängig vom Format. */ export async function tabelleLesen( daten: Buffer, dateiname: string, ): Promise { if (daten.length > MAX_DATEIGROESSE) { throw new Error( `Die Datei ist zu groß (${Math.round(daten.length / 1024 / 1024)} MB, erlaubt sind ${MAX_DATEIGROESSE / 1024 / 1024} MB).`, ); } const format = formatErkennen(dateiname); if (format === "xlsx") return excelLesen(daten); if (format === "csv") return csvLesen(textAusPuffer(daten)); throw new Error( `Das Format von "${dateiname}" wird nicht unterstützt. Erlaubt sind CSV und Excel (.xlsx).`, ); } /** * Text aus dem Puffer. Deutsche Exporte kommen oft noch als Windows-1252 — * eine BOM oder ungültige UTF-8-Folgen verraten das. */ export function textAusPuffer(daten: Buffer): string { // UTF-8 mit BOM if (daten.length >= 3 && daten[0] === 0xef && daten[1] === 0xbb && daten[2] === 0xbf) { return daten.subarray(3).toString("utf8"); } const alsUtf8 = daten.toString("utf8"); // Das Ersetzungszeichen zeigt an, dass es kein gültiges UTF-8 war. if (alsUtf8.includes("�")) { return daten.toString("latin1"); } return alsUtf8; } // ----------------------------------------------------------------------------- // Schritt 2: Zuordnung und Vorschau // ----------------------------------------------------------------------------- /** * Erzeugt die Vorschau. Schreibt nichts — das ist der ganze Sinn. * * @param zuordnung optional; ohne Angabe wird automatisch zugeordnet */ export function vorschauErstellen( tabelle: GeleseneTabelle, art: ImportArt, zuordnung?: Record, ): ImportVorschau { const felder = IMPORT_ARTEN[art].felder; const zuord = zuordnung ?? spaltenZuordnen(tabelle.spalten, felder); const zugeordneteFelder = new Set( Object.values(zuord).filter((wert): wert is string => Boolean(wert)), ); const fehlendePflichtfelder = felder .filter((feld) => feld.pflicht && !zugeordneteFelder.has(feld.schluessel)) .map((feld) => feld.bezeichnung); const zeilen: VorschauZeile[] = tabelle.zeilen.map((rohzeile, index) => { const werte: Record = {}; const fehler: ZellFehler[] = []; for (const [spalte, feldSchluessel] of Object.entries(zuord)) { if (!feldSchluessel) continue; const feld = felder.find((f) => f.schluessel === feldSchluessel); if (!feld) continue; const ergebnis = wertLesen(feld, rohzeile[spalte] ?? ""); if (!ergebnis.ok) { fehler.push({ zeile: index + 1, spalte, feld: feld.bezeichnung, meldung: ergebnis.fehler ?? "Ungültiger Wert", }); continue; } if (ergebnis.wert !== undefined) werte[feldSchluessel] = ergebnis.wert; } // Pflichtfelder, für die es gar keine Spalte gibt, melden wir je Zeile — // sonst sieht man in der Vorschau nicht, dass die Zeile unbrauchbar ist. for (const feld of felder) { if (!feld.pflicht) continue; if (werte[feld.schluessel] !== undefined) continue; if (fehler.some((f) => f.feld === feld.bezeichnung)) continue; fehler.push({ zeile: index + 1, spalte: "—", feld: feld.bezeichnung, meldung: `${feld.bezeichnung} fehlt`, }); } return { nummer: index + 1, werte, rohwerte: rohzeile, fehler, uebernehmbar: fehler.length === 0, }; }); // Dubletten innerhalb der Datei aufspüren — die fallen sonst erst beim // Schreiben auf, und dann ist die halbe Datei schon drin. markiereDubletten(zeilen, art); return { art, felder, spalten: tabelle.spalten, zuordnung: zuord, zeilen, zeilenGesamt: tabelle.zeilenGesamt, gekuerzt: tabelle.gekuerzt, fehlendePflichtfelder, anzahlUebernehmbar: zeilen.filter((zeile) => zeile.uebernehmbar).length, anzahlFehlerhaft: zeilen.filter((zeile) => !zeile.uebernehmbar).length, }; } /** Welches Feld eine Zeile innerhalb der Datei eindeutig macht. */ const SCHLUESSELFELD: Record = { geraet: "inventarnummer", person: "mitgliedsnummer", fahrzeug: "funkrufname", }; function markiereDubletten(zeilen: VorschauZeile[], art: ImportArt): void { const feld = SCHLUESSELFELD[art]; const gesehen = new Map(); for (const zeile of zeilen) { const wert = zeile.werte[feld]; if (typeof wert !== "string" || !wert) continue; const schluessel = wert.toLowerCase(); const zuerst = gesehen.get(schluessel); if (zuerst !== undefined) { zeile.fehler.push({ zeile: zeile.nummer, spalte: feld, feld, meldung: `Kommt in dieser Datei schon in Zeile ${zuerst} vor`, }); zeile.uebernehmbar = false; } else { gesehen.set(schluessel, zeile.nummer); } } }