diff --git a/package.json b/package.json index d64a944..d576120 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,10 @@ "author": "", "license": "ISC", "dependencies": { + "basic-ftp": "^5.0.5", "cors": "^2.8.5", "dotenv": "^16.4.5", + "exceljs": "^4.4.0", "express": "^4.19.0", "multer": "^2.0.2", "pdf-lib": "^1.17.1", diff --git a/src/api/controllers/batch-controller.js b/src/api/controllers/batch-controller.js new file mode 100644 index 0000000..4153abf --- /dev/null +++ b/src/api/controllers/batch-controller.js @@ -0,0 +1,144 @@ +/** + * 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).'); + 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, label, zeitSuffix = '' }) { + const alle = await fetchBatchPending(clientId); + 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 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, 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 }; diff --git a/src/api/routes/batch-routes.js b/src/api/routes/batch-routes.js new file mode 100644 index 0000000..eb625f8 --- /dev/null +++ b/src/api/routes/batch-routes.js @@ -0,0 +1,14 @@ +const express = require('express'); +const router = express.Router(); +const { authenticateApiToken } = require('../middleware/auth'); +const batchController = require('../controllers/batch-controller'); + +/** + * POST /api/batch/build + * Bündelt offene batch_submissions (xlsx + Sammel-Druck-PDF) und lädt sie per FTP + * hoch. Body: { client_id?, label?, time_suffix? }. Ohne client_id → alle Zugänge. + * Wird vom Directus-Knopf (ein Zugang, sofort) und intern vom Cron genutzt. + */ +router.post('/build', authenticateApiToken, batchController.buildBundles); + +module.exports = router; diff --git a/src/config/index.js b/src/config/index.js index 1d67b96..1080f72 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -55,5 +55,18 @@ module.exports = { clientSecret: process.env.PAYPAL_CLIENT_SECRET || '', // 'sandbox' oder 'live' environment: process.env.PAYPAL_ENVIRONMENT || 'sandbox' - } + }, + + // Alternativer Übermittlungsweg (batch_submissions): Ziel-FTP für die täglichen + // Bündel (xlsx + Sammel-Druck-PDF). Passwort NUR über ENV. + ftp: { + host: process.env.SKRIFT_FTP_HOST || '', + port: parseInt(process.env.SKRIFT_FTP_PORT, 10) || 21, + user: process.env.SKRIFT_FTP_USER || '', + password: process.env.SKRIFT_FTP_PASSWORD || '', + // explizites FTP über TLS (wie im FTP-Client eingestellt). 'false' schaltet ab. + secure: String(process.env.SKRIFT_FTP_SECURE || 'true').toLowerCase() !== 'false', + // Selbstsigniertes Zertifikat zulassen (viele FTP-Server) – auf 'false' setzen für strikte Prüfung. + rejectUnauthorized: String(process.env.SKRIFT_FTP_REJECT_UNAUTHORIZED || 'false').toLowerCase() === 'true', + }, }; diff --git a/src/server.js b/src/server.js index ce865e4..f24d176 100644 --- a/src/server.js +++ b/src/server.js @@ -5,7 +5,9 @@ const config = require('./config'); const previewRoutes = require('./api/routes/preview-routes'); const orderRoutes = require('./api/routes/order-routes'); const chargeRoutes = require('./api/routes/charge-routes'); +const batchRoutes = require('./api/routes/batch-routes'); const healthRoutes = require('./api/routes/health-routes'); +const { runScheduledBatch, berlinParts } = require('./api/controllers/batch-controller'); const errorHandler = require('./api/middleware/error-handler'); const requestLogger = require('./api/middleware/request-logger'); @@ -49,6 +51,7 @@ app.use('/health', healthRoutes); app.use('/api/preview', previewRoutes); app.use('/api/order', orderRoutes); app.use('/api/charge', chargeRoutes); +app.use('/api/batch', batchRoutes); // Format-Cache manuell auffrischen (Token-geschützt) – für „sofort übernehmen". app.post('/api/formats/refresh', async (req, res) => { @@ -80,6 +83,19 @@ app.listen(config.port, () => { // Formate initial laden und stündlich auffrischen (zentral aus Directus). refreshRenderFormats(); setInterval(refreshRenderFormats, 60 * 60 * 1000); + + // Batch-Bündelung: täglich um 03:00 (Europe/Berlin) den Vortag bündeln + FTP. + // Sa+So werden gemeinsam am Montag verarbeitet (Sonntag-Lauf wird übersprungen). + // Minuten-Tick; einmal pro Tag im 3-Uhr-Fenster (idempotent über letzterBatchTag). + let letzterBatchTag = null; + setInterval(() => { + try { + const { date, hour } = berlinParts(); + if (hour !== 3 || letzterBatchTag === date) return; + letzterBatchTag = date; // markiert den Lauf für heute – auch bei Fehler kein Doppel-Lauf. + runScheduledBatch().catch((e) => console.error('[batch-cron] Fehler:', e.message)); + } catch (e) { console.error('[batch-cron] Tick-Fehler:', e.message); } + }, 60 * 1000); }); // Graceful shutdown diff --git a/src/services/directus-service.js b/src/services/directus-service.js index 8e5e146..fdb7653 100644 --- a/src/services/directus-service.js +++ b/src/services/directus-service.js @@ -110,4 +110,22 @@ async function fetchRenderFormats() { ); } -module.exports = { fetchOrder, updateOrder, addStatusHistory, fetchRenderFormats, fetchOrdersMinimal, fetchAssetBuffer }; +/** Offene (pending) Batch-Übermittlungen MIT Druck-PDF, optional je Zugang. + * Text-only-Datensätze (ohne PDF) werden bewusst NICHT gebündelt. */ +async function fetchBatchPending(clientId) { + const filter = 'filter[status][_eq]=pending&filter[pdf_file][_nnull]=true' + + (clientId != null ? `&filter[api_client][_eq]=${encodeURIComponent(clientId)}` : ''); + const fields = 'id,api_client,firma,vorname,nachname,zeile1,zeile2,zeile3,plz,stadt,land,pdf_file'; + return (await directusFetch(`/items/batch_submissions?${filter}&sort=date_created&limit=-1&fields=${fields}`)) || []; +} + +/** Markiert Datensätze als gebündelt (nach erfolgreichem FTP-Upload). */ +async function markBatchBundled(ids, bundleName) { + if (!ids || !ids.length) return; + return directusFetch('/items/batch_submissions', { + method: 'PATCH', + body: JSON.stringify({ keys: ids, data: { status: 'bundled', bundle_name: bundleName, bundled_at: new Date().toISOString() } }), + }); +} + +module.exports = { fetchOrder, updateOrder, addStatusHistory, fetchRenderFormats, fetchOrdersMinimal, fetchAssetBuffer, fetchBatchPending, markBatchBundled };