From 3b21608ca4f67d402c4d97232ed1e41aadc74ac8 Mon Sep 17 00:00:00 2001 From: s4luorth Date: Thu, 1 Oct 2026 17:38:44 +0200 Subject: [PATCH] Empfaenger-Datei-Import: POST /api/order/parse-recipients (XLSX/CSV nach Spaltennamen) Liest hochgeladene Empfaengerdateien (Base64) nach Spaltennamen ein - wie die Web-App (lib/excel.ts): feste Spalten (Anrede/Vorname/Nachname/Strasse/Nr/PLZ/Ort/Land), "Zeile N" -> Freitext, unbekannte Spalten -> Platzhalter. XLSX via exceljs, CSV/TSV robust (Trenner-Erkennung, Anfuehrungszeichen). Wird vom Directus-Neuauftrag genutzt. Co-Authored-By: Claude Opus 4.8 --- src/api/controllers/order-controller.js | 108 ++++++++++++++++++++++++ src/api/routes/order-routes.js | 7 ++ 2 files changed, 115 insertions(+) diff --git a/src/api/controllers/order-controller.js b/src/api/controllers/order-controller.js index d2f1ccb..46c21d4 100644 --- a/src/api/controllers/order-controller.js +++ b/src/api/controllers/order-controller.js @@ -598,6 +598,113 @@ async function registerOrder(req, res, next) { } } +// ── Empfänger-Datei einlesen (XLSX/CSV) nach Spaltennamen ──────────────────── +// Spiegelt die Logik der Web-App (skrift-app/src/lib/excel.ts): feste Spalten, +// „Zeile N" → Freitext, unbekannte Spalten → Platzhalter. +const EMPF_SPALTEN = { + anrede: 'salutation', vorname: 'first_name', nachname: 'last_name', name: 'last_name', + strasse: 'street', 'straße': 'street', nr: 'house_no', hausnummer: 'house_no', + plz: 'zip', ort: 'city', stadt: 'city', land: 'country', +}; +const normSpalte = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ''); + +function zelleText(v) { + if (v === null || v === undefined) return ''; + if (v instanceof Date) return v.toISOString().slice(0, 10); + if (typeof v === 'object') { + if (v.text !== undefined) return String(v.text); + if (v.result !== undefined) return String(v.result); + if (v.richText) return v.richText.map((t) => t.text).join(''); + if (v.hyperlink !== undefined) return String(v.text || v.hyperlink); + return ''; + } + return String(v); +} + +// Robuster CSV/TSV-Parser (Trenner-Erkennung, Anführungszeichen, "" = "). +function parseCsvText(text) { + const erste = String(text).replace(/\r\n?/g, '\n').replace(/^/, '').split('\n')[0] || ''; + const anz = (d) => erste.split(d).length - 1; + const delim = anz('\t') > 0 ? '\t' : ((anz(';') > 0 && anz(';') >= anz(',')) ? ';' : ','); + const s = String(text).replace(/\r\n?/g, '\n').replace(/^/, ''); + const rows = []; let row = []; let cur = ''; let q = false; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (q) { if (c === '"') { if (s[i + 1] === '"') { cur += '"'; i++; } else { q = false; } } else { cur += c; } } + else if (c === '"') { q = true; } + else if (c === delim) { row.push(cur); cur = ''; } + else if (c === '\n') { row.push(cur); rows.push(row); row = []; cur = ''; } + else { cur += c; } + } + if (cur !== '' || row.length) { row.push(cur); rows.push(row); } + return rows.filter((r) => r.some((c) => String(c).trim() !== '')); +} + +async function tabelleAusDatei(buffer, filename) { + const ext = String(filename || '').toLowerCase(); + const istCsv = ext.endsWith('.csv') || ext.endsWith('.tsv') || ext.endsWith('.txt'); + if (!istCsv) { + try { + const ExcelJS = require('exceljs'); + const wb = new ExcelJS.Workbook(); + await wb.xlsx.load(buffer); + const ws = wb.worksheets[0]; + if (ws) { + const breite = ws.actualColumnCount || ws.columnCount || 0; + const rows = []; + ws.eachRow({ includeEmpty: false }, (zeile) => { + const a = []; + for (let c = 1; c <= breite; c++) a.push(zelleText(zeile.getCell(c).value)); + rows.push(a); + }); + if (rows.length) return rows; + } + } catch (e) { /* keine gültige XLSX → als CSV versuchen */ } + } + return parseCsvText(buffer.toString('utf8')); +} + +/** + * POST /api/order/parse-recipients + * Body: { data_base64, filename }. Antwort: { entries, columns, count }. + */ +async function parseRecipients(req, res, next) { + try { + const b64 = String((req.body && req.body.data_base64) || '').replace(/^data:[^,]*,/, ''); + const filename = (req.body && req.body.filename) || ''; + if (!b64) return res.status(400).json({ error: 'Keine Datei übermittelt.' }); + const buffer = Buffer.from(b64, 'base64'); + if (!buffer.length) return res.status(400).json({ error: 'Leere Datei.' }); + + const tab = await tabelleAusDatei(buffer, filename); + if (tab.length < 2) return res.status(422).json({ error: 'Keine Datenzeilen – Datei braucht eine Kopfzeile mit Spaltennamen und mindestens einen Empfänger.' }); + + const kopf = tab[0].map((h) => String(h || '').trim()); + const erkannt = new Set(); + const entries = []; + for (let r = 1; r < tab.length; r++) { + const zellen = tab[r]; + const e = { salutation: '', first_name: '', last_name: '', street: '', house_no: '', zip: '', city: '', country: '', free_text: '', placeholders: {} }; + const freitextZeilen = []; + kopf.forEach((spalte, ci) => { + if (!spalte) return; + const wert = String(zellen[ci] ?? '').trim(); + const zm = /^zeile\s*(\d+)$/i.exec(spalte); + const feld = EMPF_SPALTEN[normSpalte(spalte)]; + if (zm) { freitextZeilen[Number(zm[1]) - 1] = wert; erkannt.add('Zeile ' + zm[1]); } + else if (feld) { e[feld] = wert; erkannt.add(spalte); } + else if (wert) { e.placeholders[spalte] = wert; erkannt.add(spalte); } + }); + if (freitextZeilen.length) e.free_text = freitextZeilen.map((z) => z || '').join('\n').replace(/\n+$/, ''); + entries.push(e); + } + return res.json({ entries, columns: [...erkannt], count: entries.length }); + } catch (err) { + console.error('[Order] parseRecipients:', err); + return res.status(500).json({ error: 'Datei konnte nicht gelesen werden.' }); + } +} + module.exports = { finalizeOrder, generateOrder, @@ -605,4 +712,5 @@ module.exports = { uploadMotif, uploadOrderFile, registerOrder, + parseRecipients, }; diff --git a/src/api/routes/order-routes.js b/src/api/routes/order-routes.js index 8e1bd53..207bcb1 100644 --- a/src/api/routes/order-routes.js +++ b/src/api/routes/order-routes.js @@ -133,6 +133,13 @@ router.post('/files', authenticateApiToken, uploadOrderFile.single('file'), orde */ router.post('/register', authenticateApiToken, orderController.registerOrder); +/** + * POST /api/order/parse-recipients + * Liest eine hochgeladene Empfängerdatei (XLSX/CSV als Base64) nach Spaltennamen ein. + * Body: { data_base64, filename } + */ +router.post('/parse-recipients', authenticateApiToken, orderController.parseRecipients); + /** * POST /api/order/from-directus * Generiert einen Auftrag, dessen Daten in Directus liegen.