Files
skrift-backend/src/api/controllers/batch-controller.js
s4luorth 2c2282e05a
All checks were successful
Build & Deploy / deploy (push) Successful in 11s
FTP-Diagnose: user + Passwort-Laenge beim Login loggen (kein Leak)
Bei "530 Login incorrect" trotz FileZilla-OK zeigt pw_len im Backend-Log,
ob das Passwort im .env die falsche Laenge hat (Quotes/CRLF/Leerzeichen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-28 10:59:50 +02:00

149 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Batch-Bündelung (alternativer Übermittlungsweg)
* ---------------------------------------------------------------------------
* Fasst offene batch_submissions JE ZUGANG (api_client) zusammen:
* • xlsx im festen Schema (Firma, Vorname, Nachname, Zeile1-3, PLZ, Stadt, Land)
* • Sammel-Druck-PDF: alle mitgelieferten Druck-PDFs 1:1 aneinandergehängt
* und lädt beide per FTP(S) in die Wurzel des Ziel-Servers. Danach werden die
* Datensätze als „bundled" markiert. Text-only-Datensätze (ohne PDF) bleiben
* offen (für die manuelle Schreibvorlage/Plotter) und wandern NICHT ins Bündel.
*
* Ausgelöst durch: Cron (03:00 Folgetag; Sa+So gemeinsam) und den Directus-Knopf
* (POST /api/batch/build mit client_id = sofortige Bündelung des aktuellen Stands).
*/
const { Readable } = require('stream');
const { PDFDocument } = require('pdf-lib');
const ExcelJS = require('exceljs');
const ftp = require('basic-ftp');
const config = require('../../config');
const { fetchBatchPending, markBatchBundled, fetchAssetBuffer } = require('../../services/directus-service');
const XLSX_HEADER = ['Firma', 'Vorname', 'Nachname', 'Zeile1', 'Zeile2', 'Zeile3', 'PLZ', 'Stadt', 'Land'];
/** Datum/Uhrzeit in Europe/Berlin (für Cron-Fenster + Dateinamen, TZ-unabhängig). */
function berlinParts(d = new Date()) {
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', weekday: 'short', hour12: false,
});
const p = Object.fromEntries(fmt.formatToParts(d).map((x) => [x.type, x.value]));
return { date: `${p.year}-${p.month}-${p.day}`, hour: parseInt(p.hour, 10), minute: parseInt(p.minute, 10), weekday: p.weekday };
}
/** Kalendertag (Berlin) minus N Tage → YYYY-MM-DD. Mittag-UTC vermeidet DST-Sprünge. */
function datumMinus(dateStr, tage) {
const [y, m, d] = String(dateStr).split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d - tage, 12, 0, 0)).toISOString().slice(0, 10);
}
function buildXlsx(subs) {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Empfaenger');
ws.addRow(XLSX_HEADER);
ws.getRow(1).font = { bold: true };
for (const s of subs) {
ws.addRow([s.firma || '', s.vorname || '', s.nachname || '', s.zeile1 || '', s.zeile2 || '',
s.zeile3 || '', s.plz || '', s.stadt || '', s.land || '']);
}
ws.columns.forEach((c) => { c.width = 18; });
return wb.xlsx.writeBuffer().then((ab) => Buffer.from(ab));
}
async function buildMergedPdf(subs) {
const merged = await PDFDocument.create();
let seiten = 0;
for (const s of subs) {
if (!s.pdf_file) continue;
try {
const buf = await fetchAssetBuffer(s.pdf_file);
const src = await PDFDocument.load(buf, { ignoreEncryption: true });
const pages = await merged.copyPages(src, src.getPageIndices());
pages.forEach((p) => merged.addPage(p));
seiten += pages.length;
} catch (e) {
console.error(`[batch] PDF ${s.pdf_file} (Datensatz ${s.id}) übersprungen: ${e.message}`);
}
}
return { bytes: Buffer.from(await merged.save()), seiten };
}
async function ftpUpload(dateien) {
const { host, port, user, password, secure, rejectUnauthorized } = config.ftp;
if (!host || !user) throw new Error('FTP nicht konfiguriert (SKRIFT_FTP_HOST/USER fehlen).');
// Diagnose ohne Passwort-Leak: bei „530 Login incorrect" trotz korrekter Daten
// ist meist die Passwort-Laenge im .env falsch (Quotes/CRLF/Leerzeichen).
console.log(`[batch] FTP-Login: host=${host}:${port} user="${user}" pw_len=${(password || '').length} secure=${secure}`);
const client = new ftp.Client(30000);
// client.ftp.verbose = true;
try {
await client.access({ host, port, user, password, secure, secureOptions: { rejectUnauthorized } });
for (const f of dateien) await client.uploadFrom(Readable.from(f.buffer), f.name);
} finally {
client.close();
}
}
/** Bündelt EINEN Zugang: xlsx + Sammel-PDF bauen, hochladen, Datensätze markieren. */
async function buendleZugang(clientId, subs, label, zeitSuffix) {
const withPdf = subs.filter((s) => s.pdf_file);
if (!withPdf.length) return { client_id: clientId, count: 0, skipped: 'keine Druck-PDFs' };
const basis = `${clientId}_${label}${zeitSuffix ? `_${zeitSuffix}` : ''}`;
const xlsx = await buildXlsx(withPdf);
const { bytes: pdf, seiten } = await buildMergedPdf(withPdf);
await ftpUpload([
{ name: `${basis}.xlsx`, buffer: xlsx },
{ name: `${basis}.pdf`, buffer: pdf },
]);
await markBatchBundled(withPdf.map((s) => s.id), basis);
console.log(`[batch] Zugang ${clientId}: ${withPdf.length} Datensätze / ${seiten} PDF-Seiten → ${basis}.{xlsx,pdf} hochgeladen.`);
return { client_id: clientId, count: withPdf.length, pages: seiten, file: basis };
}
/** Bündelt alle offenen Datensätze (optional nur ein Zugang), gruppiert je Zugang. */
async function buendleAlle({ clientId = null, ids = null, label, zeitSuffix = '' }) {
const alle = await fetchBatchPending(clientId, ids);
const proZugang = new Map();
for (const s of alle) {
const k = String(s.api_client);
if (!proZugang.has(k)) proZugang.set(k, []);
proZugang.get(k).push(s);
}
const ergebnis = [];
for (const [k, subs] of proZugang) {
try { ergebnis.push(await buendleZugang(k, subs, label, zeitSuffix)); }
catch (e) { console.error(`[batch] Zugang ${k} fehlgeschlagen: ${e.message}`); ergebnis.push({ client_id: k, error: e.message }); }
}
return ergebnis;
}
/** HTTP: POST /api/batch/build { client_id?, label?, time_suffix? }
* Ohne client_id → alle Zugänge (wie der Cron). label default = heute (Berlin). */
async function buildBundles(req, res) {
try {
const clientId = (req.body && req.body.client_id != null && String(req.body.client_id).trim()) ? String(req.body.client_id).trim() : null;
const ids = (req.body && Array.isArray(req.body.ids) && req.body.ids.length) ? req.body.ids : null;
const label = (req.body && req.body.label) ? String(req.body.label) : berlinParts().date;
// Manuelle Bündelung mit Uhrzeit-Suffix, damit sie den Cron-Tageslauf (nur Datum) nicht überschreibt.
const zeitSuffix = (req.body && req.body.time_suffix !== undefined)
? String(req.body.time_suffix)
: `${String(berlinParts().hour).padStart(2, '0')}${String(berlinParts().minute).padStart(2, '0')}`;
const ergebnis = await buendleAlle({ clientId, ids, label, zeitSuffix });
return res.json({ ok: true, bundles: ergebnis });
} catch (err) {
console.error('[batch] build fehlgeschlagen:', err);
return res.status(500).json({ error: 'Batch-Bündelung fehlgeschlagen', message: err.message });
}
}
/** Cron-Lauf: alle Zugänge, Label = Vortag (Berlin). Sa+So gemeinsam am Montag,
* Sonntag 03:00 wird übersprungen (Samstag wartet auf den Montagslauf). */
async function runScheduledBatch() {
const { date, weekday } = berlinParts();
if (weekday === 'Sun') { console.log('[batch-cron] Sonntag – Samstag wird am Montag mit Sonntag gebündelt, kein Lauf.'); return; }
const label = datumMinus(date, 1); // Vortag (bei Montag = Sonntag; deckt Sa+So ab)
console.log(`[batch-cron] Start Tageslauf, Label ${label} (${weekday}).`);
const ergebnis = await buendleAlle({ label, zeitSuffix: '' });
console.log(`[batch-cron] fertig: ${ergebnis.length} Zugang/Zugänge verarbeitet.`);
}
module.exports = { buildBundles, runScheduledBatch, berlinParts };