Empfaenger-Datei-Import: POST /api/order/parse-recipients (XLSX/CSV nach Spaltennamen)
All checks were successful
Build & Deploy / deploy (push) Successful in 13s

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 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-10-01 17:38:44 +02:00
parent d5a051befc
commit 3b21608ca4
2 changed files with 115 additions and 0 deletions

View File

@@ -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 = { module.exports = {
finalizeOrder, finalizeOrder,
generateOrder, generateOrder,
@@ -605,4 +712,5 @@ module.exports = {
uploadMotif, uploadMotif,
uploadOrderFile, uploadOrderFile,
registerOrder, registerOrder,
parseRecipients,
}; };

View File

@@ -133,6 +133,13 @@ router.post('/files', authenticateApiToken, uploadOrderFile.single('file'), orde
*/ */
router.post('/register', authenticateApiToken, orderController.registerOrder); 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 * POST /api/order/from-directus
* Generiert einen Auftrag, dessen Daten in Directus liegen. * Generiert einen Auftrag, dessen Daten in Directus liegen.