Default proReihe/group von 5 auf 2 gesenkt. Weiterhin über ?group=N uebersteuerbar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2307 lines
122 KiB
JavaScript
2307 lines
122 KiB
JavaScript
/**
|
||
* Skrift – Bestell-Endpunkt
|
||
* ---------------------------------------------------------------------------
|
||
* POST /skrift-orders Auftrag anlegen (Gast oder eingeloggt)
|
||
* POST /skrift-orders/quote Nur Preis berechnen (für die Live-Anzeige oben)
|
||
*
|
||
* Grundsatz: Dem Client wird beim Preis NICHT vertraut. Der Betrag wird immer
|
||
* serverseitig aus products/price_items/pricing_settings neu berechnet.
|
||
*
|
||
* Preislogik:
|
||
* - Grundpreis → editierbare Formel (pricing_settings.formula), ausgewertet
|
||
* von einem sicheren Mini-Interpreter (kein eval).
|
||
* - Aufschläge → Versand, Kuvert, Beschriftung, Zusatzleistungen: Struktur im
|
||
* Code, Werte aus price_items.
|
||
* - Zahlung → ab pricing_settings.paypal_limit_net (netto) nur noch Rechnung.
|
||
*/
|
||
|
||
import crypto from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { Readable } from 'node:stream';
|
||
|
||
/** Basisordner der generierten Auftragsdateien (read-only in Directus gemountet). */
|
||
const OUTPUT_DIR = process.env.SKRIFT_OUTPUT_DIR || '/var/skrift-output';
|
||
/** Directus-Upload-Ordner (lokaler Storage) – zum direkten Datei-Lesen. */
|
||
const UPLOAD_ROOT = process.env.STORAGE_LOCAL_ROOT || '/directus/uploads';
|
||
|
||
// ── Sicherer Formel-Interpreter (kein eval) ─────────────────────────────────
|
||
// Erlaubt: Zahlen, Variablen, + - * / ( ), unäres Minus, Vergleiche
|
||
// (>= <= > < == !=), Ternär (cond ? a : b) und Funktionen sqrt/abs/min/max/
|
||
// round/floor/ceil. Schreibweisen %var% und Math.fn werden vorab normalisiert.
|
||
const FORMULA_FUNCS = {
|
||
sqrt: Math.sqrt, abs: Math.abs, min: Math.min, max: Math.max,
|
||
round: Math.round, floor: Math.floor, ceil: Math.ceil, pow: Math.pow,
|
||
};
|
||
function evalFormula(expr, vars) {
|
||
const src = String(expr).replace(/%(\w+)%/g, '$1').replace(/Math\./g, '');
|
||
const tokens = src.match(/\d+(?:\.\d+)?|[A-Za-z_]\w*|>=|<=|==|!=|[+\-*/()?:<>,]/g);
|
||
if (!tokens) throw new Error('Formel ist leer oder ungültig.');
|
||
|
||
let pos = 0;
|
||
const peek = () => tokens[pos];
|
||
const next = () => tokens[pos++];
|
||
const expect = (t) => { if (next() !== t) throw new Error(`Erwartet "${t}" in der Formel.`); };
|
||
|
||
function parseTernary() {
|
||
const cond = parseCompare();
|
||
if (peek() === '?') {
|
||
next();
|
||
const a = parseTernary();
|
||
expect(':');
|
||
const b = parseTernary();
|
||
return cond ? a : b;
|
||
}
|
||
return cond;
|
||
}
|
||
function parseCompare() {
|
||
const left = parseAdd();
|
||
const op = peek();
|
||
if (['>=', '<=', '>', '<', '==', '!='].includes(op)) {
|
||
next();
|
||
const right = parseAdd();
|
||
if (op === '>=') return left >= right ? 1 : 0;
|
||
if (op === '<=') return left <= right ? 1 : 0;
|
||
if (op === '>') return left > right ? 1 : 0;
|
||
if (op === '<') return left < right ? 1 : 0;
|
||
if (op === '==') return left === right ? 1 : 0;
|
||
return left !== right ? 1 : 0;
|
||
}
|
||
return left;
|
||
}
|
||
function parseAdd() {
|
||
let left = parseMul();
|
||
while (peek() === '+' || peek() === '-') {
|
||
const op = next();
|
||
const right = parseMul();
|
||
left = op === '+' ? left + right : left - right;
|
||
}
|
||
return left;
|
||
}
|
||
function parseMul() {
|
||
let left = parseUnary();
|
||
while (peek() === '*' || peek() === '/') {
|
||
const op = next();
|
||
const right = parseUnary();
|
||
if (op === '/' && right === 0) throw new Error('Division durch 0 in der Formel.');
|
||
left = op === '*' ? left * right : left / right;
|
||
}
|
||
return left;
|
||
}
|
||
function parseUnary() {
|
||
if (peek() === '-') { next(); return -parseUnary(); }
|
||
return parsePrimary();
|
||
}
|
||
function parsePrimary() {
|
||
const t = next();
|
||
if (t === undefined) throw new Error('Formel unvollständig.');
|
||
if (t === '(') { const v = parseTernary(); expect(')'); return v; }
|
||
if (/^\d/.test(t)) return parseFloat(t);
|
||
if (/^[A-Za-z_]/.test(t)) {
|
||
if (peek() === '(') { // Funktionsaufruf
|
||
next();
|
||
const args = [];
|
||
if (peek() !== ')') {
|
||
args.push(parseTernary());
|
||
while (peek() === ',') { next(); args.push(parseTernary()); }
|
||
}
|
||
expect(')');
|
||
const fn = FORMULA_FUNCS[t.toLowerCase()];
|
||
if (!fn) throw new Error(`Unbekannte Funktion: "${t}"`);
|
||
return fn(...args);
|
||
}
|
||
if (Object.prototype.hasOwnProperty.call(vars, t)) return Number(vars[t]) || 0;
|
||
throw new Error(`Unbekannte Variable in der Formel: "${t}"`);
|
||
}
|
||
throw new Error(`Unerwartetes Token: "${t}"`);
|
||
}
|
||
|
||
const result = parseTernary();
|
||
if (pos !== tokens.length) throw new Error('Formel konnte nicht vollständig gelesen werden.');
|
||
if (!Number.isFinite(result)) throw new Error('Formel ergibt keinen gültigen Wert.');
|
||
return result;
|
||
}
|
||
|
||
const round2 = (n) => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
|
||
|
||
/** Adresse, an die Angebotsanfragen gemeldet werden. */
|
||
const TEAM_EMAIL = process.env.SKRIFT_TEAM_EMAIL || 'hello@skrift.de';
|
||
|
||
/** Backend, das die SVGs erzeugt. */
|
||
const BACKEND_URL = (process.env.SKRIFT_BACKEND_URL || 'http://skrift-backend:4000').replace(/\/$/, '');
|
||
const BACKEND_TOKEN = process.env.SKRIFT_BACKEND_TOKEN || '';
|
||
|
||
/** Vorschauen pro Kennung und Tag. */
|
||
const PREVIEW_LIMIT = Number(process.env.SKRIFT_PREVIEW_LIMIT || 10);
|
||
/** Wie viele Dokumente eine Vorschau maximal zurückgibt. */
|
||
const PREVIEW_MAX_DOCS = 3;
|
||
|
||
/** PayPal – Server-Zugangsdaten (Secret liegt NUR hier, nie im Frontend). */
|
||
const PAYPAL_CLIENT_ID = process.env.PAYPAL_CLIENT_ID || '';
|
||
const PAYPAL_SECRET = process.env.PAYPAL_CLIENT_SECRET || '';
|
||
const PAYPAL_API = (process.env.PAYPAL_ENV || 'sandbox') === 'live'
|
||
? 'https://api-m.paypal.com'
|
||
: 'https://api-m.sandbox.paypal.com';
|
||
|
||
/** OAuth-Token für die PayPal-REST-API holen. */
|
||
async function paypalToken() {
|
||
const auth = Buffer.from(`${PAYPAL_CLIENT_ID}:${PAYPAL_SECRET}`).toString('base64');
|
||
const r = await fetch(`${PAYPAL_API}/v1/oauth2/token`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: 'grant_type=client_credentials',
|
||
});
|
||
if (!r.ok) throw new Error(`PayPal-Token: ${r.status}`);
|
||
return (await r.json()).access_token;
|
||
}
|
||
|
||
/**
|
||
* Zielland-Erkennung je Empfänger für den Länder-Split.
|
||
* Deutschland, wenn: Land = DE/leer, „Deutschland"/„Germany" im Freitext,
|
||
* „D-" vor der PLZ, oder nur eine PLZ ohne Auslandshinweis. Sonst → weltweit (INT).
|
||
*/
|
||
// Auslandsländer als GANZE Wörter (\b) – sonst matchte „schweiz" in „Schweizer
|
||
// Straße", „usa" in „Neusass", „polen" in „Polenz" usw. und flaggte fälschlich Ausland.
|
||
const AUSLAND_RE = /\b(österreich|schweiz|austria|switzerland|frankreich|france|italien|italy|niederlande|netherlands|belgien|belgium|luxemburg|luxembourg|spanien|spain|portugal|usa|vereinigte staaten|united states|kanada|canada|england|großbritannien|grossbritannien|united kingdom|dänemark|denmark|schweden|sweden|norwegen|norway|finnland|finland|polen|poland|tschechien|czech|ungarn|hungary|griechenland|greece|irland|ireland|australien|australia|liechtenstein)\b/i;
|
||
// HTML-Escaping + Feld-Tabelle für vollständige Anfrage-Team-Mails.
|
||
const escH = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
||
function felderHtml(pairs) {
|
||
const rows = pairs
|
||
.filter(([, v]) => v != null && String(v).trim())
|
||
.map(([k, v]) => `<tr><td style="padding:3px 14px 3px 0;color:#666;vertical-align:top;white-space:nowrap;">${escH(k)}</td>`
|
||
+ `<td style="padding:3px 0;"><strong>${escH(v).replace(/\n/g, '<br>')}</strong></td></tr>`)
|
||
.join('');
|
||
return `<table style="border-collapse:collapse;font-size:14px;line-height:1.5;">${rows}</table>`;
|
||
}
|
||
|
||
// Erstes nicht-leeres Feld aus mehreren möglichen Namen (App + WordPress-Varianten).
|
||
function feldWert(body, ...keys) {
|
||
for (const k of keys) {
|
||
const v = body && body[k];
|
||
if (v != null && String(v).trim()) return String(v).trim();
|
||
}
|
||
return '';
|
||
}
|
||
function zielInland(e) {
|
||
const land = String(e?.country || '').trim();
|
||
if (land) {
|
||
if (/^(de|deu|ger|d|brd|deutschland|germany)$/i.test(land)) return true;
|
||
if (/deutschland|germany/i.test(land)) return true;
|
||
if (AUSLAND_RE.test(land)) return false;
|
||
return true; // unbekannter/leerer Ländertext → NICHT automatisch Ausland
|
||
}
|
||
const txt = String(e?.free_text || '');
|
||
if (/deutschland|germany/i.test(txt)) return true;
|
||
if (/\bd-\s?\d{4,5}\b/i.test(txt)) return true;
|
||
if (AUSLAND_RE.test(txt)) return false;
|
||
return true;
|
||
}
|
||
|
||
const handler = (router, { services, getSchema, logger }) => {
|
||
const { ItemsService, MailService, UsersService, RolesService, AssetsService, FilesService } = services;
|
||
|
||
// Signatur-Plotter-Service (intern über das npm-Netz per container_name erreichbar).
|
||
const SIGN_URL = (process.env.SKRIFT_SIGN_URL || 'http://skrift-signature-service:8000').replace(/\/$/, '');
|
||
|
||
/** Liest eine Directus-Datei (Upload) serverseitig als Buffer – direkt von der
|
||
* Platte (lokaler Storage), wie der Mailer. Vermeidet AssetsService.getAsset,
|
||
* dessen Signatur je nach Directus-Version „transforms"-Fehler wirft. */
|
||
async function dateiBytes(schema, id) {
|
||
const f = await new ItemsService('directus_files', { schema, accountability: null })
|
||
.readOne(id, { fields: ['filename_disk'] });
|
||
if (!f?.filename_disk) throw new Error(`Datei ${id} nicht gefunden.`);
|
||
return fs.readFileSync(path.join(UPLOAD_ROOT, f.filename_disk));
|
||
}
|
||
|
||
/** Erzeugt aus einem hochgeladenen Motiv das A6-Drucklayout (152×109 mm, 2 mm
|
||
* Beschnitt, Endformat 148×105) – Motiv mittig, randabfallend – und legt es als
|
||
* Datei ab (orders.motif_layout). Rein serverseitig, für die Kundenfreigabe. */
|
||
async function erzeugeMotivEntwurf(schema, orderId, fileId) {
|
||
const meta = await new ItemsService('directus_files', { schema, accountability: null })
|
||
.readOne(fileId, { fields: ['type', 'filename_disk'] });
|
||
if (!meta?.filename_disk) throw new Error(`Motiv-Datei ${fileId} nicht gefunden.`);
|
||
const base64 = fs.readFileSync(path.join(UPLOAD_ROOT, meta.filename_disk)).toString('base64');
|
||
const mime = meta.type || 'image/png';
|
||
// A6 quer: Motiv wird VOLLSTÄNDIG in die Trim-Fläche (148×105) eingepasst
|
||
// (mittig, nichts beschnitten) auf weißem A6-Grund inkl. 2 mm Beschnitt.
|
||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="152mm" height="109mm" viewBox="0 0 152 109">`
|
||
+ `<rect x="0" y="0" width="152" height="109" fill="#ffffff"/>`
|
||
+ `<image href="data:${mime};base64,${base64}" x="2" y="2" width="148" height="105" preserveAspectRatio="xMidYMid meet"/>`
|
||
+ `</svg>`;
|
||
const files = new FilesService({ schema, accountability: null });
|
||
const storage = (process.env.STORAGE_LOCATIONS || 'local').split(',')[0].trim() || 'local';
|
||
const layoutId = await files.uploadOne(
|
||
Readable.from(Buffer.from(svg, 'utf8')),
|
||
{ storage, filename_download: `a6-entwurf-${orderId}.svg`, title: `A6-Entwurf ${orderId}`, type: 'image/svg+xml' },
|
||
undefined, { emitEvents: false },
|
||
);
|
||
await new ItemsService('orders', { schema, accountability: null }).updateOne(orderId, { motif_layout: layoutId });
|
||
return layoutId;
|
||
}
|
||
|
||
/** Baut die Multipart-Anfrage an den Signatur-Service (Vorschau/Render). */
|
||
async function signatureForm(schema, pdfId, sigList) {
|
||
const pdfBytes = await dateiBytes(schema, pdfId);
|
||
const form = new FormData();
|
||
form.append('pdf', new Blob([pdfBytes], { type: 'application/pdf' }), 'dokument.pdf');
|
||
const jobs = [];
|
||
for (let i = 0; i < sigList.length; i += 1) {
|
||
const s = sigList[i] || {};
|
||
if (!s.file_id) continue;
|
||
const svgBytes = await dateiBytes(schema, s.file_id);
|
||
const svgName = `sig${i + 1}.svg`;
|
||
form.append('svgs', new Blob([svgBytes], { type: 'image/svg+xml' }), svgName);
|
||
const marker = String(s.marker_key || `SIG${i + 1}`).replace(/[§[\]]/g, '').trim();
|
||
jobs.push({
|
||
token: `[[${marker}]]`,
|
||
svg_id: svgName,
|
||
position: ['genau', 'ueber', 'unter'].includes(s.position) ? s.position : 'genau',
|
||
breite_mm: Number(s.width_mm) > 0 ? Number(s.width_mm) : 45,
|
||
versatz_x_mm: Number(s.offset_x_mm) || 0,
|
||
versatz_y_mm: Number(s.offset_y_mm) || 0,
|
||
});
|
||
}
|
||
form.append('job', JSON.stringify(jobs));
|
||
return { form, jobCount: jobs.length };
|
||
}
|
||
|
||
/** Gemeinsame Vorbereitung für /signature-preview und /signature-render. */
|
||
async function signatureAufruf(req, res, pfad, contentType, key) {
|
||
const acc = req.accountability;
|
||
if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
|
||
const body = req.body || {};
|
||
// Entweder aus einem Auftrag (order) ODER manuell mit direkt hochgeladenem
|
||
// Dokument (document_id) – eins von beiden muss vorhanden sein.
|
||
if (!body.order && !body.document_id) return res.status(400).json({ error: 'order oder document_id erforderlich.' });
|
||
try {
|
||
const schema = await getSchema();
|
||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||
let ord = null;
|
||
if (body.order) {
|
||
ord = await svc('orders').readOne(body.order, { fields: ['id', 'order_number', 'source_file', 'source_files'] });
|
||
if (!ord) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
|
||
}
|
||
const pdfId = body.document_id || (ord ? (ord.source_file || (Array.isArray(ord.source_files) ? ord.source_files[0] : null)) : null);
|
||
if (!pdfId) return res.status(400).json({ error: 'Kein Dokument (Auftrag ohne Upload bzw. keine document_id).' });
|
||
const sigList = Array.isArray(body.signatures) ? body.signatures : [];
|
||
if (!sigList.length) return res.status(400).json({ error: 'Mindestens eine Signatur wählen.' });
|
||
|
||
const { form, jobCount } = await signatureForm(schema, pdfId, sigList);
|
||
if (!jobCount) return res.status(400).json({ error: 'Keine gültige Signatur (SVG-Datei fehlt).' });
|
||
|
||
let r;
|
||
try {
|
||
r = await fetch(`${SIGN_URL}${pfad}`, { method: 'POST', body: form });
|
||
} catch (e) {
|
||
// Netzwerkfehler (Service down / Containername nicht auflösbar / falsche URL).
|
||
logger.warn(`[skrift-orders] Signatur-Service nicht erreichbar (${SIGN_URL}): ${e.message}`);
|
||
return res.status(502).json({ error: 'Signatur-Service nicht erreichbar.', detail: `${SIGN_URL} – ${e.message}. SKRIFT_SIGN_URL / Container prüfen.` });
|
||
}
|
||
if (!r.ok) {
|
||
const t = await r.text();
|
||
return res.status(502).json({ error: 'Signatur-Service-Fehler (evtl. Marker nicht im PDF gefunden).', detail: `${r.status}: ${String(t).slice(0, 400)}` });
|
||
}
|
||
const buf = Buffer.from(await r.arrayBuffer());
|
||
return res.json({ [key]: buf.toString('base64'), content_type: contentType, order_number: ord?.order_number || 'manuell' });
|
||
} catch (err) {
|
||
logger.error(`[skrift-orders] ${pfad}: ${err.stack || err.message}`);
|
||
return res.status(500).json({ error: 'Signatur-Verarbeitung fehlgeschlagen.', detail: String(err.message || err).slice(0, 400) });
|
||
}
|
||
}
|
||
|
||
// Vorschau-PDF (Dokument mit eingefügten Unterschriften).
|
||
router.post('/signature-preview', (req, res) => signatureAufruf(req, res, '/preview', 'application/pdf', 'pdf_base64'));
|
||
// A4-Signatur-SVGs als ZIP (zum Plotten in der lokalen Produktion).
|
||
router.post('/signature-render', (req, res) => signatureAufruf(req, res, '/render', 'application/zip', 'zip_base64'));
|
||
|
||
// Unterschriftenservice als ECHTEN Auftrag anlegen: erzeugt je PDF-Seite eine
|
||
// A4-SVG (leer, wo kein Marker), schreibt sie als Schriftstück in den Auftrags-
|
||
// ordner und legt den Auftrag an. Danach läuft alles über die normale Produktion
|
||
// (Drucken → Plotter). KEIN neuer Job-Typ. Setzt den schreibbaren Output-Mount voraus.
|
||
router.post('/signature-order', async (req, res) => {
|
||
const acc = req.accountability;
|
||
if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
|
||
const body = req.body || {};
|
||
if (!body.order && !body.document_id) return res.status(400).json({ error: 'order oder document_id erforderlich.' });
|
||
const sigList = Array.isArray(body.signatures) ? body.signatures : [];
|
||
if (!sigList.length) return res.status(400).json({ error: 'Mindestens eine Unterschrift wählen.' });
|
||
try {
|
||
const schema = await getSchema();
|
||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||
|
||
let ord = null;
|
||
if (body.order) {
|
||
ord = await svc('orders').readOne(body.order, { fields: ['id', 'source_file', 'source_files'] });
|
||
if (!ord) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
|
||
}
|
||
const pdfId = body.document_id || (ord ? (ord.source_file || (Array.isArray(ord.source_files) ? ord.source_files[0] : null)) : null);
|
||
if (!pdfId) return res.status(400).json({ error: 'Kein Dokument (document_id fehlt).' });
|
||
|
||
const prodKey = body.product || 'unterschriftenservice';
|
||
let product = (await svc('products').readByQuery({ filter: { key: { _eq: prodKey } }, limit: 1, fields: ['id', 'key', 'name'] }))?.[0];
|
||
if (!product) product = (await svc('products').readByQuery({ filter: { input_mode: { _eq: 'datei' } }, limit: 1, fields: ['id', 'key', 'name'] }))?.[0];
|
||
if (!product) return res.status(400).json({ error: 'Kein Unterschriftenservice-/Datei-Produkt gefunden.' });
|
||
|
||
let customerId = null, customerEmail = null;
|
||
if (body.customer_email && isEmail(norm(body.customer_email))) {
|
||
const u = (await new UsersService({ schema, accountability: null }).readByQuery({
|
||
filter: { email: { _eq: norm(body.customer_email) } }, limit: 1, fields: ['id', 'email'],
|
||
}))?.[0];
|
||
if (u) { customerId = u.id; customerEmail = u.email; } else customerEmail = norm(body.customer_email);
|
||
}
|
||
|
||
// A4-Signaturen erzeugen (eine je Seite) – JSON-Variante.
|
||
const { form, jobCount } = await signatureForm(schema, pdfId, sigList);
|
||
if (!jobCount) return res.status(400).json({ error: 'Keine gültige Signatur (SVG-Datei fehlt).' });
|
||
let r;
|
||
try { r = await fetch(`${SIGN_URL}/render-json`, { method: 'POST', body: form }); }
|
||
catch (e) { return res.status(502).json({ error: 'Signatur-Service nicht erreichbar.', detail: `${SIGN_URL} – ${e.message}` }); }
|
||
if (!r.ok) {
|
||
let d = ''; try { d = (await r.json())?.detail || ''; } catch { /* ignore */ }
|
||
return res.status(502).json({ error: 'Signatur-Service-Fehler (Marker im PDF?).', detail: String(Array.isArray(d) ? d.join('; ') : d).slice(0, 400) });
|
||
}
|
||
const pages = (await r.json())?.pages || [];
|
||
if (!pages.length) return res.status(422).json({ error: 'Keine Seiten erzeugt.' });
|
||
|
||
const jt = new Date();
|
||
const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`;
|
||
const naechsteNummer = async () => {
|
||
const heutige = await svc('orders').readByQuery({ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'] });
|
||
return `${praefix}-${String((heutige?.length ?? 0) + 1).padStart(3, '0')}`;
|
||
};
|
||
const orderData = {
|
||
customer: customerId, customer_email: customerEmail, product: product.id,
|
||
person_type: 'privat', source: 'operator', status: 'in_queue', production_status: 'eingegangen',
|
||
payment_status: 'bezahlt', payment_method: 'rechnung', shipping_type: 'sammel',
|
||
needs_envelope: false, envelope_labeling: 'keine',
|
||
source_file: pdfId, source_files: [pdfId],
|
||
entries_count: pages.length, count_inland: pages.length, count_ausland: 0,
|
||
};
|
||
let orderId, orderNumber;
|
||
for (let v = 0; v < 6; v += 1) {
|
||
orderNumber = await naechsteNummer();
|
||
try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; }
|
||
catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; }
|
||
}
|
||
|
||
const entriesSvc = svc('order_entries');
|
||
for (let i = 0; i < pages.length; i += 1) {
|
||
await entriesSvc.createOne({ order: orderId, letter_number: i + 1, free_text: `Seite ${i + 1}` });
|
||
}
|
||
await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Unterschriftenservice – manuell angelegt.' }).catch(() => {});
|
||
|
||
// Seiten-SVGs als Schriftstück in den Auftragsordner schreiben.
|
||
const ordner = path.join(OUTPUT_DIR, orderNumber);
|
||
fs.mkdirSync(ordner, { recursive: true });
|
||
pages.forEach((p, i) => {
|
||
fs.writeFileSync(path.join(ordner, `letter_${String(i + 1).padStart(3, '0')}.svg`), p.svg, 'utf8');
|
||
});
|
||
|
||
return res.json({ ok: true, order_number: orderNumber, id: orderId, pages: pages.length });
|
||
} catch (err) {
|
||
logger.error(`[skrift-orders] signature-order: ${err.stack || err.message}`);
|
||
return res.status(500).json({ error: 'Auftrag konnte nicht angelegt werden.', detail: String(err.message || err).slice(0, 400) });
|
||
}
|
||
});
|
||
|
||
// Policy-konformes Zufallspasswort (Kunde meldet sich ohnehin passwortlos an).
|
||
const zufallsPasswort = () =>
|
||
`Aa1!${crypto.randomBytes(24).toString('base64url').replace(/[^A-Za-z0-9]/g, '')}`;
|
||
|
||
const norm = (e) => String(e || '').trim().toLowerCase();
|
||
const isEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||
|
||
/** Entfernt alle nicht erlaubten Zeichen (Whitelist aus pricing_settings). */
|
||
function makeSanitizer(whitelist) {
|
||
let re;
|
||
try { re = new RegExp(`[^${whitelist}\\n\\r\\t]`, 'g'); }
|
||
catch { re = null; }
|
||
return (v) => (typeof v === 'string' && re ? v.replace(re, '') : v);
|
||
}
|
||
|
||
async function loadPricingContext(schema) {
|
||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||
const [settingsRaw, priceItems] = await Promise.all([
|
||
svc('pricing_settings').readSingleton({}),
|
||
svc('price_items').readByQuery({ filter: { active: { _eq: true } }, limit: -1,
|
||
fields: ['key', 'price', 'unit', 'category'] }),
|
||
]);
|
||
const settings = settingsRaw || {};
|
||
const byKey = Object.fromEntries((priceItems || []).map((p) => [p.key, p]));
|
||
return { settings, priceItems: byKey };
|
||
}
|
||
|
||
/** Berechnet den Preis vollständig serverseitig. */
|
||
async function calculatePrice(schema, input) {
|
||
const { settings, priceItems } = await loadPricingContext(schema);
|
||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||
|
||
const product = (await svc('products').readByQuery({
|
||
filter: { key: { _eq: input.product } }, limit: 1,
|
||
fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'norm_qty', 'price_factor_a', 'price_factor_e', 'input_mode', 'active'],
|
||
}))?.[0];
|
||
if (!product || !product.active) throw new Error('Unbekanntes oder inaktives Produkt.');
|
||
if (product.pricing_mode === 'auf_anfrage')
|
||
throw new Error(`"${product.name}" ist nur auf Anfrage bestellbar.`);
|
||
|
||
const menge = Math.max(0, parseInt(input.quantity, 10) || 0);
|
||
if (menge < 1) throw new Error('Menge muss mindestens 1 sein.');
|
||
|
||
const lines = [];
|
||
// perUnit=true kennzeichnet Positionen, die pro Stück anfallen (fließen in den
|
||
// „Preis pro Stück"); einmalige Kosten (Sammelversand, Gutschein …) nicht.
|
||
const add = (label, amount, perUnit = false) => { if (amount) lines.push({ label, amount: round2(amount), per_unit: perUnit }); };
|
||
const priceOf = (key) => Number(priceItems[key]?.price) || 0;
|
||
const unitOf = (key) => priceItems[key]?.unit || 'einmalig';
|
||
const applyUnit = (key) => (unitOf(key) === 'pro_stueck' ? priceOf(key) * menge : priceOf(key));
|
||
|
||
// 1) Grundpreis über die editierbare Formel × Mengen-Multiplikator.
|
||
// Der Multiplikator bildet den Kleinmengen-Aufschlag ab und ist je Segment
|
||
// (B2B/B2C) editierbar. Variablen: qty, norm_b/norm_c, mind_b/mind_c, base_price.
|
||
// Eine gemeinsame Formel; die Normalpreis-Menge kommt vom Produkt.
|
||
const multFormula = settings.multiplier_formula || '1';
|
||
// ACHTUNG: nicht `norm` nennen – das würde die Sanitizer-Funktion norm()
|
||
// überschatten, die weiter unten für norm(input.email) gebraucht wird.
|
||
const normMenge = Number(product.norm_qty) || 0;
|
||
// Kleinmengen-Aufschlag je Produkt: a = Deckel, e = Abkling-Exponent.
|
||
// Leer/ungültig → 1 bzw. 3 (bisheriges Verhalten). 0 bleibt gültig (kein Aufschlag).
|
||
const zahlOder = (v, d) => (v === null || v === undefined || v === '' || !Number.isFinite(Number(v))) ? d : Number(v);
|
||
const faktorA = zahlOder(product.price_factor_a, 1);
|
||
const faktorE = zahlOder(product.price_factor_e, 3);
|
||
const multVars = {
|
||
qty: menge, menge, norm: normMenge,
|
||
a: faktorA, e: faktorE,
|
||
base_price: Number(product.base_price) || 0,
|
||
};
|
||
// Ohne Normalpreis-Menge gibt es keinen Kleinmengen-Aufschlag (Multiplikator 1).
|
||
let multiplier = 1;
|
||
if (normMenge > 0) {
|
||
try { multiplier = evalFormula(multFormula, multVars); } catch { multiplier = 1; }
|
||
}
|
||
if (!Number.isFinite(multiplier) || multiplier <= 0) multiplier = 1;
|
||
|
||
// Grundpreis-Formel. Variablen sind bewusst nur base_price und menge.
|
||
// Ist die Formel fehlerhaft (z. B. versehentlich die Multiplikator-Formel
|
||
// eingetragen), fällt der Preis auf die Standardformel zurück, statt die
|
||
// gesamte Preisauskunft scheitern zu lassen.
|
||
const formula = settings.formula || 'base_price * menge';
|
||
const grundVars = { base_price: Number(product.base_price) || 0, menge };
|
||
let basis;
|
||
try {
|
||
basis = evalFormula(formula, grundVars);
|
||
} catch (e) {
|
||
logger.warn(`[skrift-orders] Grundpreis-Formel ungültig ("${formula}"): ${e.message} – nutze base_price * menge.`);
|
||
basis = evalFormula('base_price * menge', grundVars);
|
||
}
|
||
if (!Number.isFinite(basis)) basis = grundVars.base_price * menge;
|
||
const grund = basis * multiplier;
|
||
add(`${product.name} (${menge} Stk.)`, grund, true);
|
||
|
||
// 1b) Mengenrabatt-Staffel: höchste passende Stufe (auf den Grundpreis).
|
||
const tiers = Array.isArray(settings.quantity_discounts) ? settings.quantity_discounts : [];
|
||
const stufe = tiers
|
||
.filter((t) => menge >= (Number(t.min_qty) || 0) && Number(t.percent) > 0)
|
||
.sort((a, b) => (Number(b.min_qty) || 0) - (Number(a.min_qty) || 0)
|
||
|| (Number(b.percent) || 0) - (Number(a.percent) || 0))[0];
|
||
if (stufe) add(`Mengenrabatt (${stufe.percent} %)`, -(grund * (Number(stufe.percent) / 100)), true);
|
||
|
||
// 2) Versand – Kuvert ist bei Einzelversand Pflicht
|
||
if (input.shipping_type === 'einzeln') {
|
||
const auslandCount = (input.entries || [])
|
||
.filter((e) => e.country && !/^(de|deutschland|germany)$/i.test(String(e.country).trim())).length;
|
||
const inlandCount = menge - auslandCount;
|
||
add('Porto Inland', priceOf('porto_inland') * inlandCount, true);
|
||
add('Porto Ausland', priceOf('porto_ausland') * auslandCount, true);
|
||
} else {
|
||
// Sammelversand-Staffel: bis 5 Schriftstücke günstiger (sammelversand_klein),
|
||
// ab 6 der reguläre Satz. Fallback 1,80 €, falls der kleine Satz fehlt.
|
||
const sammelKlein = priceItems['sammelversand_klein'] ? priceOf('sammelversand_klein') : 1.80;
|
||
add('Sammelversand', menge <= 5 ? sammelKlein : priceOf('sammelversand'), false);
|
||
}
|
||
|
||
// 3) Kuvert + Beschriftung – NUR bei Sammelversand.
|
||
// Bei Einzelversand an die Empfänger ist Kuvert + Beschriftung bereits im
|
||
// Einzelversand-Wert (Porto) enthalten und wird NICHT separat berechnet.
|
||
if (input.needs_envelope && input.shipping_type !== 'einzeln') {
|
||
add('Kuvert', applyUnit('kuvert'), unitOf('kuvert') === 'pro_stueck');
|
||
if (input.envelope_labeling && input.envelope_labeling !== 'keine')
|
||
add('Kuvert-Beschriftung', applyUnit('beschriftung'), unitOf('beschriftung') === 'pro_stueck');
|
||
}
|
||
|
||
// 4) Motiv-Upload + Zusatzleistungen (Schreib-/Gestaltungsservice usw.)
|
||
if (input.motif_mode === 'upload') add('Motiv-Upload', applyUnit('motiv_upload'), unitOf('motiv_upload') === 'pro_stueck');
|
||
for (const key of input.addons || []) {
|
||
const item = priceItems[key];
|
||
if (!item || item.category !== 'zusatzleistung') continue;
|
||
add(item.key, applyUnit(key), unitOf(key) === 'pro_stueck');
|
||
}
|
||
|
||
// 4b) Kundenrabatt – nur bei bekanntem Konto (E-Mail). Rabatt gilt auf den
|
||
// Schriftstück-Grundpreis. „erste_n_stueck" zählt KUMULATIV über alle
|
||
// bisherigen (nicht stornierten) Aufträge des Kunden.
|
||
if (input.email && isEmail(norm(input.email))) {
|
||
try {
|
||
const users = new UsersService({ schema, accountability: null });
|
||
const kunde = (await users.readByQuery({
|
||
filter: { email: { _eq: norm(input.email) } }, limit: 1, fields: ['id'],
|
||
}))?.[0];
|
||
if (kunde) {
|
||
const jetzt = new Date().toISOString();
|
||
const rabatt = (await svc('customer_discounts').readByQuery({
|
||
filter: {
|
||
customer: { _eq: kunde.id }, active: { _eq: true },
|
||
_or: [{ valid_until: { _null: true } }, { valid_until: { _gte: jetzt } }],
|
||
},
|
||
limit: 1, sort: ['-value'],
|
||
fields: ['label', 'type', 'value', 'scope', 'limit_stueck'],
|
||
}))?.[0];
|
||
if (rabatt && Number(rabatt.value) > 0) {
|
||
const proStueck = menge > 0 ? grund / menge : 0;
|
||
let stueck = menge; // scope 'alle'
|
||
if (rabatt.scope === 'erste_n_stueck') {
|
||
const frueher = await svc('orders').readByQuery({
|
||
filter: { customer: { _eq: kunde.id }, status: { _neq: 'storniert' } },
|
||
limit: -1, fields: ['entries_count'],
|
||
});
|
||
const genutzt = (frueher || []).reduce((s, o) => s + (Number(o.entries_count) || 0), 0);
|
||
stueck = Math.min(menge, Math.max(0, (Number(rabatt.limit_stueck) || 0) - genutzt));
|
||
}
|
||
if (stueck > 0) {
|
||
const betrag = rabatt.type === 'prozent'
|
||
? proStueck * stueck * (Number(rabatt.value) / 100)
|
||
: Number(rabatt.value) * stueck;
|
||
add(`Kundenrabatt${rabatt.label ? ` (${rabatt.label})` : ''}`, -Math.min(betrag, grund), true);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { logger.warn(`[skrift-orders] Kundenrabatt übersprungen: ${e.message}`); }
|
||
}
|
||
|
||
let net = lines.reduce((s, l) => s + l.amount, 0);
|
||
|
||
// 5) Gutschein
|
||
// Kundenangebot (customer_offers): frei benannte Aufschläge pro Stück, wenn der
|
||
// eingeloggte Kunde ein Angebot gewählt hat. Serverseitig geprüft (Zugehörigkeit).
|
||
let offer = null;
|
||
if (input.offer) {
|
||
const off = (await svc('customer_offers').readByQuery({
|
||
filter: { id: { _eq: input.offer }, active: { _eq: true } }, limit: 1,
|
||
fields: ['id', 'customer', 'product.key', 'name_suffix', 'surcharges', 'allow_multi_page'],
|
||
}))?.[0];
|
||
// Nur anwenden, wenn das Angebot dem anfragenden Kunden UND dem Produkt gehört.
|
||
if (off && input.customerId && String(off.customer) === String(input.customerId)
|
||
&& (!off.product?.key || off.product.key === input.product)) {
|
||
offer = off;
|
||
for (const s of (Array.isArray(off.surcharges) ? off.surcharges : [])) {
|
||
const betrag = Number(s?.amount) || 0;
|
||
if (betrag) { const gesamt = betrag * menge; add(s.label || 'Aufschlag', gesamt, true); net += gesamt; }
|
||
}
|
||
}
|
||
}
|
||
|
||
let voucher = null;
|
||
if (input.voucher_code) {
|
||
const v = (await svc('vouchers').readByQuery({
|
||
filter: { code: { _eq: String(input.voucher_code).trim() }, active: { _eq: true } },
|
||
limit: 1, fields: ['id', 'code', 'type', 'value', 'valid_until', 'max_uses', 'used_count'],
|
||
}))?.[0];
|
||
const expired = v?.valid_until && new Date(v.valid_until).getTime() < Date.now();
|
||
const exhausted = v?.max_uses != null && (v.used_count ?? 0) >= v.max_uses;
|
||
if (v && !expired && !exhausted) {
|
||
const discount = v.type === 'prozent' ? net * (Number(v.value) / 100) : Number(v.value);
|
||
const capped = Math.min(discount, net);
|
||
add(`Gutschein ${v.code}`, -capped);
|
||
net -= capped;
|
||
voucher = v;
|
||
}
|
||
}
|
||
|
||
net = round2(Math.max(0, net));
|
||
// Netto-Summe NUR der pro-Stück-Positionen (für „Preis pro Stück" ohne Einmalkosten).
|
||
const unitNet = round2(Math.max(0, lines.filter((l) => l.per_unit).reduce((s, l) => s + l.amount, 0)));
|
||
const mwst = Number(settings.mwst_percent ?? 19);
|
||
const vat = round2(net * (mwst / 100));
|
||
const gross = round2(net + vat);
|
||
|
||
// 6) Zahlungsart: ab Limit nur noch Rechnung
|
||
const limit = Number(settings.paypal_limit_net ?? 200);
|
||
const paypalAllowed = net < limit;
|
||
|
||
return { product, lines, net_total: net, unit_net_total: unitNet, vat_amount: vat, gross_total: gross,
|
||
mwst_percent: mwst, paypal_allowed: paypalAllowed, paypal_limit_net: limit, voucher, offer };
|
||
}
|
||
|
||
function validate(body) {
|
||
const errors = [];
|
||
if (!isEmail(norm(body?.email))) errors.push('Gültige E-Mail-Adresse fehlt.');
|
||
if (!body?.product) errors.push('Produkt fehlt.');
|
||
const entries = Array.isArray(body?.entries) ? body.entries : [];
|
||
if (entries.length < 1) errors.push('Mindestens eine Empfängerzeile erforderlich.');
|
||
// Kuvert-Pflicht bei Einzelversand (ohne Kuvert → Lieferung an den Besteller)
|
||
if (body?.shipping_type === 'einzeln' && !body?.needs_envelope)
|
||
errors.push('Bei Einzelversand an die Empfänger ist ein Kuvert erforderlich.');
|
||
return errors;
|
||
}
|
||
|
||
// ── Nur Preis berechnen (Live-Anzeige) ───────────────────────────────────
|
||
router.post('/quote', async (req, res) => {
|
||
try {
|
||
const schema = await getSchema();
|
||
const body = req.body || {};
|
||
const quote = await calculatePrice(schema, {
|
||
...body, quantity: body.quantity ?? (body.entries || []).length,
|
||
customerId: req.accountability?.user || null,
|
||
});
|
||
return res.json({
|
||
lines: quote.lines, net_total: quote.net_total, unit_net_total: quote.unit_net_total,
|
||
vat_amount: quote.vat_amount,
|
||
gross_total: quote.gross_total, mwst_percent: quote.mwst_percent,
|
||
paypal_allowed: quote.paypal_allowed,
|
||
});
|
||
} catch (err) {
|
||
return res.status(400).json({ error: err.message });
|
||
}
|
||
});
|
||
|
||
// ── Angebote des eingeloggten Kunden (Auswahl im Konfigurator) ────────────
|
||
router.get('/my-offers', async (req, res) => {
|
||
const uid = req.accountability?.user;
|
||
if (!uid) return res.json({ offers: [] });
|
||
try {
|
||
const schema = await getSchema();
|
||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||
const filter = { customer: { _eq: uid }, active: { _eq: true } };
|
||
if (req.query.product) filter.product = { key: { _eq: String(req.query.product) } };
|
||
const offers = await svc('customer_offers').readByQuery({
|
||
filter, limit: -1, sort: ['label'],
|
||
fields: ['id', 'label', 'name_suffix', 'note', 'allow_multi_page', 'surcharges', 'product.key', 'product.name'],
|
||
});
|
||
return res.json({ offers: offers || [] });
|
||
} catch (err) {
|
||
logger.error(`[skrift-orders] my-offers: ${err.stack || err.message}`);
|
||
return res.status(500).json({ error: 'Angebote konnten nicht geladen werden.' });
|
||
}
|
||
});
|
||
|
||
// ── Vorschau ─────────────────────────────────────────────────────────────
|
||
/**
|
||
* Erzeugt bis zu PREVIEW_MAX_DOCS Vorschau-SVGs über das Backend.
|
||
* Das Kontingent wird serverseitig geführt – jede Generierung kostet einen
|
||
* Scriptalizer-Aufruf, deshalb darf der Client das nicht bestimmen.
|
||
*/
|
||
router.post('/preview', async (req, res) => {
|
||
const body = req.body || {};
|
||
// Reine Zeilen-/Überlauf-Prüfung (beim „Weiter") verbraucht KEIN Kontingent
|
||
// und liefert keine SVGs zurück – nur has_overflow/overflow.
|
||
const nurPruefen = body.validate === true;
|
||
// Angemeldete Admin/App-Nutzer (Produktions-Modul) umgehen Kennung + Kontingent.
|
||
const istIntern = !!(req.accountability && (req.accountability.admin || req.accountability.app));
|
||
const kennung = norm(body.email) || String(body.client_id || '').slice(0, 64);
|
||
if (!kennung && !nurPruefen && !istIntern) return res.status(400).json({ error: 'E-Mail oder Kennung erforderlich.' });
|
||
// Ohne Brieftext nur erlaubt, wenn Kuverts gewünscht sind (dann nur Kuvert-Vorschau).
|
||
const willKuvertVorschau = !!(body.envelope_labeling && body.envelope_labeling !== 'keine');
|
||
if ((!body.text || !String(body.text).trim()) && !willKuvertVorschau)
|
||
return res.status(400).json({ error: 'Kein Text für die Vorschau.' });
|
||
|
||
try {
|
||
const schema = await getSchema();
|
||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||
const heute = new Date().toISOString().slice(0, 10);
|
||
|
||
const vorhanden = (nurPruefen || istIntern) ? null : (await svc('preview_usage').readByQuery({
|
||
filter: { key: { _eq: kennung }, day: { _eq: heute } }, limit: 1, fields: ['id', 'count'],
|
||
}))?.[0];
|
||
const verbraucht = vorhanden?.count ?? 0;
|
||
if (!nurPruefen && !istIntern && verbraucht >= PREVIEW_LIMIT) {
|
||
return res.status(429).json({
|
||
error: `Vorschau-Kontingent für heute aufgebraucht (${PREVIEW_LIMIT}).`,
|
||
remaining: 0, limit: PREVIEW_LIMIT,
|
||
});
|
||
}
|
||
|
||
const { settings } = await loadPricingContext(schema);
|
||
const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
|
||
|
||
const entries = (Array.isArray(body.entries) ? body.entries : []).slice(0, PREVIEW_MAX_DOCS);
|
||
const font = ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda';
|
||
const realistisch = body.realistic !== false;
|
||
// App-Formatschlüssel → Backend-Formate (wie im directus-controller).
|
||
// Ohne diese Abbildung rendert das Backend unbekannte Keys als A4.
|
||
// Bekannte App-Schlüssel übersetzen; eigene Format-Schlüssel (freie
|
||
// Formate) unverändert durchreichen – das Backend kennt sie aus render_formats.
|
||
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
|
||
const briefFormat = LETTER_FORMAT[body.format] || body.format || 'a4';
|
||
// A4 → DIN Lang, A6 → C6
|
||
const umschlagFormat = briefFormat === 'a4' ? 'din_lang' : 'c6';
|
||
// Postkarte (Text links, Adresse rechts, ein SVG) – Erkennung über render_formats.kind.
|
||
const istPk = (await svc('render_formats').readByQuery({
|
||
filter: { key: { _eq: briefFormat } }, limit: 1, fields: ['kind'],
|
||
}))?.[0]?.kind === 'postkarte';
|
||
|
||
const platzhalterVon = (e, i) => {
|
||
// 5 Adresszeilen (5-Zeilen-Modus) auch als [[Zeile1]]..[[Zeile5]].
|
||
const zeilen = String(e.free_text || '').split('\n');
|
||
return {
|
||
Anrede: clean(e.salutation || ''),
|
||
Vorname: clean(e.first_name || ''),
|
||
Nachname: clean(e.last_name || ''),
|
||
Strasse: clean([e.street, e.house_no].filter(Boolean).join(' ')),
|
||
PLZ: clean(e.zip || ''),
|
||
Ort: clean(e.city || ''),
|
||
Land: clean(e.country || ''),
|
||
Briefnummer: String(i + 1),
|
||
Zeile1: clean(zeilen[0] || ''), Zeile2: clean(zeilen[1] || ''), Zeile3: clean(zeilen[2] || ''),
|
||
Zeile4: clean(zeilen[3] || ''), Zeile5: clean(zeilen[4] || ''),
|
||
...(e.placeholders && typeof e.placeholders === 'object'
|
||
? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
|
||
: {}),
|
||
};
|
||
};
|
||
|
||
const adressBlock = (e) => {
|
||
if (e.free_text) return clean(String(e.free_text));
|
||
const name = [e.first_name, e.last_name].filter(Boolean).join(' ');
|
||
const strasse = [e.street, e.house_no].filter(Boolean).join(' ');
|
||
const ort = [e.zip, e.city].filter(Boolean).join(' ');
|
||
return clean([name, strasse, ort].filter(Boolean).join('\n'));
|
||
};
|
||
|
||
// Relative Schriftgröße (manuelle Aufträge), getrennt für Brief/Kuvert. 1 = Standard.
|
||
const skala = (v) => { const n = Number(v); return Number.isFinite(n) ? Math.max(0.8, Math.min(1.5, n)) : 1; };
|
||
const fsLetter = skala(body.font_scale_letter);
|
||
const fsEnv = skala(body.font_scale_envelope);
|
||
// Signaturen (SVG je [[signaturN]]) + Feinjustierung – nur Brief, nicht Kuvert.
|
||
const sigMap = (body.signatures && typeof body.signatures === 'object') ? body.signatures : {};
|
||
const sigOff = (body.signature_offsets && typeof body.signature_offsets === 'object') ? body.signature_offsets : {};
|
||
// Mehrseitiges Schriftstück: Text fließt über mehrere Seiten (kein Überlauf-Fehler).
|
||
const mehrseitig = body.multi_page === true;
|
||
const basis = entries.length ? entries : [{}];
|
||
const letters = [];
|
||
basis.forEach((e, i) => {
|
||
// Dateinummer 1-basiert = Briefnummer (letter_001.svg ↔ Brief 1).
|
||
const nr = i + 1;
|
||
// Abweichender Text je Schriftstück (#5) hat Vorrang; sonst der Standardtext.
|
||
const briefText = (e.text && String(e.text).trim()) ? String(e.text) : String(body.text);
|
||
|
||
// Postkarte: EIN Dokument mit Text + Absender + Empfänger (Marker-getrennt).
|
||
// Ohne Brieftext KEINE Karte erzeugen (dann greift ggf. nur der Kuvert-Zweig).
|
||
if (istPk && String(briefText).trim()) {
|
||
const sender = clean(String(body.envelope_sender || '')).trim();
|
||
const recip = adressBlock(e);
|
||
let combined = String(clean(briefText) || '');
|
||
if (sender) combined += `[[pk_abs]]${sender}`;
|
||
if (recip) combined += `[[pk_adr]]${recip}`;
|
||
letters.push({
|
||
index: nr, type: 'postcard', text: combined, format: briefFormat, font,
|
||
realisticHandwriting: realistisch, placeholders: platzhalterVon(e, i),
|
||
fontScale: fsLetter, senderScale: Number(body.envelope_sender_scale) || 1, recipientScale: fsEnv,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Kein Schriftstück, wenn kein Text da ist (reiner Kuvert-Druck).
|
||
if (String(briefText).trim()) {
|
||
letters.push({
|
||
index: nr, type: 'letter', text: clean(briefText),
|
||
format: briefFormat, font, realisticHandwriting: realistisch,
|
||
placeholders: platzhalterVon(e, i), fontScale: fsLetter,
|
||
signatures: sigMap, signatureOffsets: sigOff, multiPage: mehrseitig,
|
||
});
|
||
}
|
||
|
||
// Umschlag nur, wenn er auch beschriftet wird.
|
||
if (body.envelope_labeling && body.envelope_labeling !== 'keine') {
|
||
const istFreitext = body.envelope_labeling === 'freitext';
|
||
letters.push({
|
||
index: nr, type: 'envelope',
|
||
envelopeType: istFreitext ? 'custom' : 'recipient',
|
||
text: istFreitext ? clean(String(body.envelope_text || '')) : adressBlock(e),
|
||
format: umschlagFormat, font, realisticHandwriting: realistisch,
|
||
placeholders: platzhalterVon(e, i), fontScale: fsEnv,
|
||
// Absender aufs Kuvert (oben links); Empfänger rutscht dann unten rechts.
|
||
sender: clean(String(body.envelope_sender || '')),
|
||
senderScale: Number(body.envelope_sender_scale) || 1,
|
||
});
|
||
}
|
||
});
|
||
|
||
const sessionId = `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||
const kopf = { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN };
|
||
|
||
// Das Backend liest `letters` direkt aus dem Body (nicht aus `config`).
|
||
// Umschläge stecken als Einträge mit type:'envelope' bereits in `letters`.
|
||
const antwort = await fetch(`${BACKEND_URL}/api/preview/batch`, {
|
||
method: 'POST',
|
||
headers: kopf,
|
||
body: JSON.stringify({ sessionId, letters }),
|
||
});
|
||
if (!antwort.ok) {
|
||
const txt = await antwort.text();
|
||
logger.error(`[skrift-orders] Vorschau-Backend: ${antwort.status} ${txt}`);
|
||
// Grund für die Diagnose mitgeben (Status + gekürzte Backend-Meldung).
|
||
return res.status(502).json({
|
||
error: 'Vorschau konnte nicht erzeugt werden.',
|
||
detail: `Backend ${antwort.status}: ${String(txt).slice(0, 200)}`,
|
||
});
|
||
}
|
||
const daten = await antwort.json();
|
||
|
||
// Zeilen-Überlauf (Schriftstück zu lang für das Format) durchreichen.
|
||
const overflow = (daten.overflowFiles || [])
|
||
.map((f) => ({ index: f.index, lineCount: f.lineCount, lineLimit: f.lineLimit }));
|
||
|
||
// Reine Prüfung (beim „Weiter"): kein Download, kein Kontingent-Verbrauch.
|
||
if (nurPruefen) {
|
||
return res.json({ has_overflow: !!daten.hasOverflow, overflow });
|
||
}
|
||
|
||
// SVGs direkt einsammeln – so braucht der Browser keinen Backend-Zugang.
|
||
// Schriftstücke und Umschläge kommen gemeinsam zurück.
|
||
// Umschlag vs. Schriftstück steckt im Dateinamen-Präfix (envelope_ / letter_) –
|
||
// die Datei-Objekte des Backends tragen kein eigenes type-Feld.
|
||
const docs = [];
|
||
for (const datei of (daten.files || []).slice(0, PREVIEW_MAX_DOCS * 2)) {
|
||
const r = await fetch(`${BACKEND_URL}${datei.url}`, { headers: { 'X-API-Token': BACKEND_TOKEN } });
|
||
if (r.ok) {
|
||
const istUmschlag = /(^|\/)envelope/.test(datei.filename || datei.url || '');
|
||
docs.push({ art: istUmschlag ? 'umschlag' : 'brief', svg: await r.text(), page: datei.page, pageCount: datei.pageCount });
|
||
}
|
||
}
|
||
|
||
// Verbrauch erst nach Erfolg hochzählen – intern (Admin) zählt nicht.
|
||
if (!istIntern && !nurPruefen && kennung) {
|
||
if (vorhanden) await svc('preview_usage').updateOne(vorhanden.id, { count: verbraucht + 1 });
|
||
else await svc('preview_usage').createOne({ key: kennung, day: heute, count: 1 });
|
||
}
|
||
|
||
return res.json({
|
||
docs, remaining: PREVIEW_LIMIT - (verbraucht + 1), limit: PREVIEW_LIMIT,
|
||
has_overflow: !!daten.hasOverflow, overflow,
|
||
// Session zurückgeben, damit die gezeigten Dokumente bei der Bestellung
|
||
// 1:1 übernommen werden können.
|
||
session: sessionId,
|
||
});
|
||
} catch (err) {
|
||
logger.error(`[skrift-orders] preview: ${err.stack || err.message}`);
|
||
return res.status(500).json({ error: 'Vorschau fehlgeschlagen.' });
|
||
}
|
||
});
|
||
|
||
/** Seitenzahl je Schriftstück (mehrseitig) – schlank, ohne Scriptalizer/SVG. */
|
||
router.post('/pagecount', async (req, res) => {
|
||
const body = req.body || {};
|
||
if (!body.text || !String(body.text).trim()) return res.status(400).json({ error: 'Text fehlt.' });
|
||
try {
|
||
const schema = await getSchema();
|
||
const { settings } = await loadPricingContext(schema);
|
||
const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
|
||
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
|
||
const briefFormat = LETTER_FORMAT[body.format] || body.format || 'a4';
|
||
const scale = Math.max(0.8, Math.min(1.5, Number(body.font_scale_letter) || 1));
|
||
const entries = (Array.isArray(body.entries) ? body.entries : []).map((e) => ({
|
||
placeholders: (e && typeof e.placeholders === 'object')
|
||
? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
|
||
: {},
|
||
}));
|
||
const r = await fetch(`${BACKEND_URL}/api/order/pagecount`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
|
||
body: JSON.stringify({ text: clean(String(body.text)), format: briefFormat, fontScale: scale, entries }),
|
||
});
|
||
if (!r.ok) return res.status(502).json({ error: 'Seitenzählung fehlgeschlagen.' });
|
||
return res.json(await r.json());
|
||
} catch (err) {
|
||
logger.error(`[skrift-orders] pagecount: ${err.stack || err.message}`);
|
||
return res.status(500).json({ error: 'Seitenzählung fehlgeschlagen.' });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Öffentlicher Datei-Upload (Motiv/Quelldatei). Gäste bestellen ohne Login und
|
||
* haben keinen Directus-Token für /files – daher hier serverseitig via FilesService
|
||
* (admin) ablegen. Datei kommt als Base64 im JSON. Nur Bilder + PDF, max. 20 MB.
|
||
*/
|
||
router.post('/upload', async (req, res) => {
|
||
const body = req.body || {};
|
||
const roh = typeof body.data_base64 === 'string' ? body.data_base64.replace(/^data:[^;]+;base64,/, '') : '';
|
||
if (!roh) return res.status(400).json({ error: 'Keine Datei übergeben.' });
|
||
const type = String(body.content_type || 'application/octet-stream');
|
||
if (!/^image\/(png|jpe?g|gif|webp|svg\+xml)$|^application\/pdf$/i.test(type)) {
|
||
return res.status(415).json({ error: 'Dateityp nicht erlaubt (nur Bilder oder PDF).' });
|
||
}
|
||
let buffer;
|
||
try { buffer = Buffer.from(roh, 'base64'); } catch { return res.status(400).json({ error: 'Ungültige Datei.' }); }
|
||
if (!buffer.length) return res.status(400).json({ error: 'Leere Datei.' });
|
||
if (buffer.length > 20 * 1024 * 1024) return res.status(413).json({ error: 'Datei zu groß (max. 20 MB).' });
|
||
try {
|
||
const schema = await getSchema();
|
||
const files = new FilesService({ schema, accountability: null });
|
||
const name = (String(body.filename || 'upload').replace(/[^\w.\- ]+/g, '_').slice(0, 120) || 'upload');
|
||
const storage = (process.env.STORAGE_LOCATIONS || 'local').split(',')[0].trim() || 'local';
|
||
const id = await files.uploadOne(
|
||
Readable.from(buffer),
|
||
{ storage, filename_download: name, title: name, type },
|
||
undefined,
|
||
{ emitEvents: false },
|
||
);
|
||
return res.json({ id });
|
||
} catch (err) {
|
||
logger.error(`[skrift-orders] upload: ${err.stack || err.message}`);
|
||
return res.status(500).json({ error: 'Upload fehlgeschlagen.' });
|
||
}
|
||
});
|
||
|
||
// ── Motiv-Freigabe (öffentlich, per Einmal-Token) ──────────────────────────
|
||
const escHtml = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
||
const freigabeAuftrag = async (schema, token, fields) => {
|
||
const t = String(token || '').trim();
|
||
if (!t) return null;
|
||
return (await new ItemsService('orders', { schema, accountability: null }).readByQuery({
|
||
filter: { approval_token: { _eq: t } }, limit: 1, fields,
|
||
}))?.[0] || null;
|
||
};
|
||
|
||
// Auftrags-/Statusinfo für die Freigabe-Seite (kein Login nötig – Token genügt).
|
||
router.get('/freigabe/:token', async (req, res) => {
|
||
try {
|
||
const schema = await getSchema();
|
||
const o = await freigabeAuftrag(schema, req.params.token,
|
||
['id', 'order_number', 'approval_status', 'approval_note', 'motif_layout', 'product.name']);
|
||
if (!o) return res.status(404).json({ error: 'Freigabe-Link ungültig oder abgelaufen.' });
|
||
return res.json({
|
||
order_number: o.order_number,
|
||
product: o.product?.name || 'Postkarte',
|
||
status: o.approval_status || 'pending',
|
||
note: o.approval_note || '',
|
||
has_layout: !!o.motif_layout,
|
||
});
|
||
} catch (e) {
|
||
logger.error(`[skrift-orders] freigabe GET: ${e.message}`);
|
||
return res.status(500).json({ error: 'Fehler beim Laden.' });
|
||
}
|
||
});
|
||
|
||
// Das A6-Entwurfs-SVG direkt ausliefern (Token-gated, kein Asset-Login nötig).
|
||
router.get('/freigabe/:token/layout', async (req, res) => {
|
||
try {
|
||
const schema = await getSchema();
|
||
const o = await freigabeAuftrag(schema, req.params.token, ['motif_layout']);
|
||
if (!o?.motif_layout) return res.status(404).send('Kein Entwurf.');
|
||
const bytes = await dateiBytes(schema, o.motif_layout);
|
||
res.setHeader('Content-Type', 'image/svg+xml');
|
||
res.setHeader('Cache-Control', 'no-store');
|
||
return res.send(bytes);
|
||
} catch (e) {
|
||
logger.error(`[skrift-orders] freigabe layout: ${e.message}`);
|
||
return res.status(500).send('Fehler.');
|
||
}
|
||
});
|
||
|
||
// Freigabe oder Änderungswunsch speichern + Team benachrichtigen.
|
||
router.post('/freigabe/:token', async (req, res) => {
|
||
const action = String(req.body?.action || '');
|
||
const note = String(req.body?.note || '').slice(0, 2000).trim();
|
||
if (!['approve', 'changes'].includes(action)) return res.status(400).json({ error: 'Ungültige Aktion.' });
|
||
if (action === 'changes' && !note) return res.status(400).json({ error: 'Bitte den Änderungswunsch beschreiben.' });
|
||
try {
|
||
const schema = await getSchema();
|
||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||
const o = await freigabeAuftrag(schema, req.params.token, ['id', 'order_number', 'approval_status']);
|
||
if (!o) return res.status(404).json({ error: 'Freigabe-Link ungültig oder abgelaufen.' });
|
||
const neu = action === 'approve' ? 'approved' : 'changes';
|
||
await svc('orders').updateOne(o.id, { approval_status: neu, approval_note: action === 'changes' ? note : null });
|
||
await svc('status_history').createOne({ order: o.id, status: 'eingegangen',
|
||
note: action === 'approve' ? 'Motiv-Entwurf vom Kunden freigegeben.' : `Motiv-Änderungswunsch: ${note}` }).catch(() => {});
|
||
await teamMail(schema,
|
||
action === 'approve' ? `✅ Motiv freigegeben: ${o.order_number}` : `✏️ Motiv-Änderungswunsch: ${o.order_number}`,
|
||
action === 'approve'
|
||
? `<p>Der Kunde hat den Motiv-Entwurf zu <strong>${escHtml(o.order_number)}</strong> freigegeben – kann in den Druck.</p>`
|
||
: `<p>Änderungswunsch zum Motiv-Entwurf <strong>${escHtml(o.order_number)}</strong>:</p><p style="white-space:pre-wrap">${escHtml(note)}</p>`);
|
||
return res.json({ ok: true, status: neu });
|
||
} catch (e) {
|
||
logger.error(`[skrift-orders] freigabe POST: ${e.message}`);
|
||
return res.status(500).json({ error: 'Konnte nicht gespeichert werden.' });
|
||
}
|
||
});
|
||
|
||
/** Aktuelles Kontingent abfragen, ohne etwas zu verbrauchen. */
|
||
router.get('/preview/quota', async (req, res) => {
|
||
try {
|
||
const kennung = norm(req.query.email) || String(req.query.client_id || '').slice(0, 64);
|
||
if (!kennung) return res.json({ remaining: PREVIEW_LIMIT, limit: PREVIEW_LIMIT });
|
||
const schema = await getSchema();
|
||
const heute = new Date().toISOString().slice(0, 10);
|
||
const row = (await new ItemsService('preview_usage', { schema, accountability: null }).readByQuery({
|
||
filter: { key: { _eq: kennung }, day: { _eq: heute } }, limit: 1, fields: ['count'],
|
||
}))?.[0];
|
||
return res.json({ remaining: Math.max(0, PREVIEW_LIMIT - (row?.count ?? 0)), limit: PREVIEW_LIMIT });
|
||
} catch {
|
||
return res.json({ remaining: PREVIEW_LIMIT, limit: PREVIEW_LIMIT });
|
||
}
|
||
});
|
||
|
||
// ── Auftragsdateien (Direktzugriff auf den Ausgabe-Ordner, keine Kopie) ────
|
||
// Zugriff: App-Nutzer (Staff/Admin) ODER der Produktions-Agent (Rolle Service).
|
||
let _serviceRoleId;
|
||
async function serviceRoleId() {
|
||
if (_serviceRoleId !== undefined) return _serviceRoleId;
|
||
try {
|
||
const rows = await new ItemsService('directus_roles', { schema: await getSchema(), accountability: null })
|
||
.readByQuery({ filter: { name: { _eq: 'Service' } }, limit: 1, fields: ['id'] });
|
||
_serviceRoleId = rows?.[0]?.id ?? null;
|
||
} catch { _serviceRoleId = null; }
|
||
return _serviceRoleId;
|
||
}
|
||
async function darfDateien(req) {
|
||
const acc = req.accountability;
|
||
if (!acc || !acc.user) return false;
|
||
if (acc.admin || acc.app) return true; // Operator im Admin
|
||
const svc = await serviceRoleId();
|
||
return !!(svc && acc.role === svc); // Produktions-Agent
|
||
}
|
||
const safeOrder = (s) => (/^[A-Za-z0-9_-]+$/.test(String(s || '')) ? String(s) : null);
|
||
|
||
/** Listet die Dateien eines Auftrags aus dem gemounteten Ausgabe-Ordner. */
|
||
function listeDateien(orderNummer) {
|
||
const basis = path.join(OUTPUT_DIR, orderNummer);
|
||
let root;
|
||
try { root = fs.readdirSync(basis); } catch { return null; } // Ordner (noch) nicht da
|
||
const out = [];
|
||
const add = (rel, kind) => {
|
||
try {
|
||
const st = fs.statSync(path.join(basis, rel));
|
||
if (st.isFile()) out.push({ name: path.basename(rel), rel, kind, size: st.size });
|
||
} catch { /* ignore */ }
|
||
};
|
||
for (const f of root) {
|
||
if (/\.csv$/i.test(f)) add(f, 'platzhalter');
|
||
else if (/\.svg$/i.test(f)) add(f, 'schriftstueck');
|
||
}
|
||
try {
|
||
for (const f of fs.readdirSync(path.join(basis, 'umschlaege'))) {
|
||
if (/\.svg$/i.test(f)) add(path.join('umschlaege', f), 'umschlag');
|
||
}
|
||
} catch { /* keine Umschläge */ }
|
||
return out;
|
||
}
|
||
|
||
router.get('/files/:order', async (req, res) => {
|
||
if (!(await darfDateien(req))) return res.status(403).json({ error: 'Kein Zugriff.' });
|
||
const nummer = safeOrder(req.params.order);
|
||
if (!nummer) return res.status(400).json({ error: 'Ungültige Auftragsnummer.' });
|
||
const files = listeDateien(nummer);
|
||
if (files === null) return res.json({ files: [], exists: false });
|
||
return res.json({ files, exists: true });
|
||
});
|
||
|
||
// Zeichenreihenfolge „spaltenweise": permutiert NUR die Glyph-<path>-Elemente
|
||
// nach x (Spalte, aufsteigend), innerhalb einer Spalte nach y (oben→unten).
|
||
// Andere Elemente (Hintergrund-Rect, Signatur-<g>, Trennstrich bleibt ein Pfad)
|
||
// behalten ihre Position. Das sichtbare Ergebnis ist identisch – nur die
|
||
// Druckreihenfolge ändert sich, sodass der rollengeführte Plotter monoton in
|
||
// Vorschubrichtung fährt statt je Zeile die Rolle zurückzuspulen (A6 quer).
|
||
function svgSpaltenweise(svg, proReihe = 2) {
|
||
// <g>…</g>-Blöcke (z. B. eingebettete Signaturen) als Ganzes schützen – deren
|
||
// innere Pfade dürfen NICHT umsortiert werden (sie hängen an einem eigenen
|
||
// Gruppen-Transform). Signaturen stehen hinter dem Text, daher greedy bis zum
|
||
// letzten </g>. Ohne <g> bleibt alles unverändert.
|
||
const bloecke = [];
|
||
const safe = svg.replace(/<g\b[\s\S]*<\/g>/, (m) => { bloecke.push(m); return ' |