Produktion: Format-Warteschlangen, Sammeldruck, Webhook, Preis-a/e
Produktions-Board: - Massenauswahl (+ "Alle auswählen") mit Sammel-Druck und Sammel-Status - Warteschlange je Maschine nach Format gebündelt; Formatabfrage (machines.current_format) statt automatischem Anschluss - resiliente Ladeabfragen (Board bleibt ohne neue Felder nutzbar) Agent (skrift-agent): - druckt strikt ein Dokument nach dem anderen (Idle-Gate) -> behebt Verlust von Folgejobs während eines laufenden Drucks - druckt nur Jobs des eingelegten Formats (current_format), kein Auto-Anschluss - Heartbeat gedrosselt (nur bei Statuswechsel / alle 60 s) Neues Modul "Manueller Druck" (skrift-neuauftrag): - interner Auftrag ohne Zahlung, Vorschau, Platzhalter/Excel-Import, Felder bleiben nach dem Absenden stehen Backend/Endpunkte (skrift-orders): - jobs tragen format/format_label; /enqueue-bulk für Sammeldruck - Webhook /inbound/:kind (JetFormBuilder) mit Token + tolerantem Body-Parser - Kleinmengen-Aufschlag je Produkt über Faktoren a (Deckel) und e (Exponent) - Fix: norm()-Shadowing in calculatePrice (norm is not a function) Bootstrap: - jobs.format/format_label, machines.current_format - products.price_factor_a/e; Multiplikator-Formel auf a/e umgestellt inkl. verhaltensneutraler Migration bestehender Formel/Produkte skrift-app (Konfigurator): - Zeilenumbrüche im Textfeld erlaubt; Umschlag-Individualtext als Textfeld - Platzhalter-Tabelle im Text-Schritt immer verfügbar (auch Sammelversand ohne Kuvert) inkl. Excel-Upload nur für Platzhalterwerte - Danke-Seite: Button zurück zu skrift.de - z-index-Fix: Platzhalter-Tabelle überlagert die Vorschau nicht mehr WordPress-Preisrechner: a/e-Faktoren in PHP- und JS-Rechnung Directus: SKRIFT_WEBHOOK_TOKEN in compose/.env.example Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -189,7 +189,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
|
||||
const product = (await svc('products').readByQuery({
|
||||
filter: { key: { _eq: input.product } }, limit: 1,
|
||||
fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'norm_qty', 'active'],
|
||||
fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'norm_qty', 'price_factor_a', 'price_factor_e', 'active'],
|
||||
}))?.[0];
|
||||
if (!product || !product.active) throw new Error('Unbekanntes oder inaktives Produkt.');
|
||||
if (product.pricing_mode === 'auf_anfrage')
|
||||
@@ -211,14 +211,22 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
// (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';
|
||||
const norm = Number(product.norm_qty) || 0;
|
||||
// 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,
|
||||
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 (norm > 0) {
|
||||
if (normMenge > 0) {
|
||||
try { multiplier = evalFormula(multFormula, multVars); } catch { multiplier = 1; }
|
||||
}
|
||||
if (!Number.isFinite(multiplier) || multiplier <= 0) multiplier = 1;
|
||||
@@ -394,8 +402,10 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
// 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) return res.status(400).json({ error: 'E-Mail oder Kennung erforderlich.' });
|
||||
if (!kennung && !nurPruefen && !istIntern) return res.status(400).json({ error: 'E-Mail oder Kennung erforderlich.' });
|
||||
if (!body.text || !String(body.text).trim())
|
||||
return res.status(400).json({ error: 'Kein Text für die Vorschau.' });
|
||||
|
||||
@@ -404,11 +414,11 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||
const heute = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const vorhanden = nurPruefen ? null : (await svc('preview_usage').readByQuery({
|
||||
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 && verbraucht >= PREVIEW_LIMIT) {
|
||||
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,
|
||||
@@ -423,8 +433,10 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
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] || 'a4';
|
||||
const briefFormat = LETTER_FORMAT[body.format] || body.format || 'a4';
|
||||
// A4 → DIN Lang, A6 → C6
|
||||
const umschlagFormat = briefFormat === 'a4' ? 'din_lang' : 'c6';
|
||||
|
||||
@@ -517,9 +529,11 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Verbrauch erst nach Erfolg hochzählen.
|
||||
if (vorhanden) await svc('preview_usage').updateOne(vorhanden.id, { count: verbraucht + 1 });
|
||||
else await svc('preview_usage').createOne({ key: kennung, day: heute, count: 1 });
|
||||
// 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,
|
||||
@@ -632,6 +646,12 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
// für die gewählte Maschine an. Kein Statuswechsel am Auftrag (bewusst).
|
||||
const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6l_v1' };
|
||||
const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
|
||||
// Lesbare Labels + Format-Schlüssel je Job-Typ. Der Schlüssel steuert die
|
||||
// Format-Warteschlange (Agent druckt nur Jobs mit passendem machines.current_format).
|
||||
const ENV_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
|
||||
const LETTER_LABEL = { a4: 'A4', a6_hoch: 'A6 hoch', a6_quer: 'A6 quer' };
|
||||
const briefFormatOf = (order) => ({ key: order.format?.key || 'a4', label: order.format?.name || LETTER_LABEL[order.format?.key] || order.format?.key || 'A4' });
|
||||
const umschlagFormatOf = (order) => ({ key: order.envelope_format || 'c6', label: ENV_LABEL[order.envelope_format] || order.envelope_format || 'C6' });
|
||||
|
||||
router.post('/enqueue', async (req, res) => {
|
||||
const acc = req.accountability;
|
||||
@@ -642,7 +662,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
const schema = await getSchema();
|
||||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||
const order = await svc('orders').readOne(body.order, {
|
||||
fields: ['id', 'order_number', 'format.key', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
|
||||
fields: ['id', 'order_number', 'format.key', 'format.name', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
|
||||
});
|
||||
if (!order) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
|
||||
|
||||
@@ -676,8 +696,10 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
const sec = s[typ];
|
||||
if (!sec || sec.enabled === false) continue;
|
||||
if (!sec.template) return res.status(400).json({ error: `Template für ${typ} fehlt.` });
|
||||
const fmt = typ === 'umschlag' ? umschlagFormatOf(order) : briefFormatOf(order);
|
||||
jobs.push(await svc('jobs').createOne({
|
||||
order: order.id, machine: body.machine, type: typ, template: sec.template,
|
||||
format: fmt.key, format_label: fmt.label,
|
||||
params: clampParams(sec.params), numbers: (typeof sec.numbers === 'string' && sec.numbers.trim()) ? sec.numbers.trim() : null,
|
||||
status: 'queued', priority: prio,
|
||||
}));
|
||||
@@ -685,14 +707,18 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
if (!jobs.length) return res.status(400).json({ error: 'Kein Abschnitt zum Drucken gewählt.' });
|
||||
} else {
|
||||
// Alte, einfache Form (Auftrags-Button): Brief + ggf. Umschlag automatisch.
|
||||
const bf = briefFormatOf(order);
|
||||
const briefTpl = await tplByKey(LETTER_TPL[order.format?.key] || 'a4_single');
|
||||
jobs.push(await svc('jobs').createOne({
|
||||
order: order.id, machine: body.machine, type: 'brief', template: briefTpl, status: 'queued', priority: prio,
|
||||
order: order.id, machine: body.machine, type: 'brief', template: briefTpl,
|
||||
format: bf.key, format_label: bf.label, status: 'queued', priority: prio,
|
||||
}));
|
||||
if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') {
|
||||
const ef = umschlagFormatOf(order);
|
||||
const envTpl = await tplByKey(ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1');
|
||||
jobs.push(await svc('jobs').createOne({
|
||||
order: order.id, machine: body.machine, type: 'umschlag', template: envTpl, status: 'queued', priority: prio,
|
||||
order: order.id, machine: body.machine, type: 'umschlag', template: envTpl,
|
||||
format: ef.key, format_label: ef.label, status: 'queued', priority: prio,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -703,6 +729,57 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Sammel-Druck: mehrere Aufträge auf einmal einreihen ────────────────────
|
||||
// Body: { orders:[id…], mode:'queue'|'direct',
|
||||
// byFormat:{ '<formatKey>': { template, machine, label } } }
|
||||
// Pro Format wird einmal Template + Maschine gewählt; hier werden daraus die
|
||||
// Jobs für alle markierten Aufträge erzeugt (Brief + ggf. Umschlag je Format).
|
||||
router.post('/enqueue-bulk', 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 || {};
|
||||
const orderIds = Array.isArray(body.orders) ? body.orders : [];
|
||||
const byFormat = (body.byFormat && typeof body.byFormat === 'object') ? body.byFormat : {};
|
||||
if (!orderIds.length) return res.status(400).json({ error: 'Keine Aufträge gewählt.' });
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||
const orders = await svc('orders').readMany(orderIds, {
|
||||
fields: ['id', 'order_number', 'format.key', 'format.name', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
|
||||
});
|
||||
|
||||
// Direktdruck bekommt hohe Priorität, außer die Zielmaschine druckt schon.
|
||||
const printing = await svc('jobs').readByQuery({ filter: { status: { _eq: 'printing' } }, limit: -1, fields: ['machine'] });
|
||||
const busySet = new Set((printing || []).map((j) => String(j.machine)));
|
||||
const mode = body.mode === 'direct' ? 'direct' : 'queue';
|
||||
const prioFor = (m) => (mode === 'direct' && !busySet.has(String(m))) ? 100 : 0;
|
||||
|
||||
const jobs = [];
|
||||
const uebersprungen = [];
|
||||
const mkJob = async (order, typ, fmt) => {
|
||||
const cfg = byFormat[fmt.key];
|
||||
if (!cfg || !cfg.template || !cfg.machine) {
|
||||
uebersprungen.push({ order: order.order_number, grund: `Kein Template/Maschine für ${typ === 'umschlag' ? 'Kuvert' : 'Format'} ${fmt.label}` });
|
||||
return;
|
||||
}
|
||||
jobs.push(await svc('jobs').createOne({
|
||||
order: order.id, machine: cfg.machine, type: typ, template: cfg.template,
|
||||
format: fmt.key, format_label: cfg.label || fmt.label, status: 'queued', priority: prioFor(cfg.machine),
|
||||
}));
|
||||
};
|
||||
for (const order of orders) {
|
||||
await mkJob(order, 'brief', briefFormatOf(order));
|
||||
if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') {
|
||||
await mkJob(order, 'umschlag', umschlagFormatOf(order));
|
||||
}
|
||||
}
|
||||
return res.json({ ok: true, jobs, mode, uebersprungen });
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-orders] enqueue-bulk: ${err.stack || err.message}`);
|
||||
return res.status(500).json({ error: 'Sammel-Einreihen fehlgeschlagen.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Testdruck: beliebiges Template + Text generieren und einreihen ─────────
|
||||
const TEST_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
|
||||
router.post('/test-print', async (req, res) => {
|
||||
@@ -742,6 +819,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
// 2) Druck-Job (ohne Auftrag, über output_ref) für die gewählte Maschine.
|
||||
const job = await svc('jobs').createOne({
|
||||
machine: tp.machine, template: tp.template, type: 'brief', status: 'queued', output_ref: ref,
|
||||
format: tp.format || 'a4', format_label: LETTER_LABEL[tp.format] || tp.format || 'A4',
|
||||
});
|
||||
await svc('test_prints').updateOne(id, { status: 'generiert', output_ref: ref });
|
||||
return res.json({ ok: true, ref, job });
|
||||
@@ -793,6 +871,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
|
||||
const job = await svc('jobs').createOne({
|
||||
machine: body.machine, template: body.template, type: 'brief', status: 'queued', output_ref: ref, priority: prio,
|
||||
format: body.format || 'a4', format_label: LETTER_LABEL[body.format] || body.format || 'A4',
|
||||
});
|
||||
if (body.offer_request) {
|
||||
try { await svc('offer_requests').updateOne(body.offer_request, { status: 'in_bearbeitung' }); } catch { /* nicht kritisch */ }
|
||||
@@ -1015,6 +1094,232 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Webhook: Anfragen direkt aus WordPress (JetFormBuilder) ────────────────
|
||||
// POST /skrift-orders/inbound/:kind (kind = 'muster' | 'anfrage')
|
||||
// Öffentlich erreichbar, daher mit Shared-Secret gesichert (SKRIFT_WEBHOOK_TOKEN,
|
||||
// per ?token= oder Header X-Webhook-Token). Feldnamen = JetFormBuilder-Felder.
|
||||
// JetFormBuilder sendet meist application/x-www-form-urlencoded – das parst
|
||||
// Directus für Custom-Endpunkte NICHT in req.body. Daher lesen wir den
|
||||
// Rohbody hier selbst und interpretieren ihn als urlencoded oder JSON.
|
||||
router.use('/inbound', (req, res, next) => {
|
||||
if (req.method !== 'POST') return next();
|
||||
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length) return next();
|
||||
let raw = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (c) => { raw += c; if (raw.length > 1000000) req.destroy(); });
|
||||
req.on('end', () => {
|
||||
const s = raw.trim();
|
||||
if (s) {
|
||||
try { req.body = JSON.parse(s); }
|
||||
catch { req.body = Object.fromEntries(new URLSearchParams(s)); }
|
||||
}
|
||||
next();
|
||||
});
|
||||
req.on('error', () => next());
|
||||
});
|
||||
|
||||
router.post('/inbound/:kind', async (req, res) => {
|
||||
// Body ist durch die /inbound-Middleware bereits geparst (JSON/urlencoded).
|
||||
let body = req.body || {};
|
||||
if (typeof body === 'string') {
|
||||
try { body = JSON.parse(body); }
|
||||
catch { body = Object.fromEntries(new URLSearchParams(body)); }
|
||||
}
|
||||
const kind = String(req.params.kind || '').toLowerCase();
|
||||
// Immer protokollieren, damit im Directus-Log sichtbar ist, was ankommt.
|
||||
logger.info(`[skrift-orders] inbound/${kind}: content-type=${req.get('content-type') || '—'}, `
|
||||
+ `felder=[${Object.keys(body).join(',')}], token=${req.query.token ? 'query' : (req.get('x-webhook-token') ? 'header' : (body.token ? 'body' : 'keiner'))}`);
|
||||
|
||||
const secret = process.env.SKRIFT_WEBHOOK_TOKEN || '';
|
||||
const provided = req.query.token || req.get('x-webhook-token') || body.token || '';
|
||||
if (!secret) {
|
||||
logger.warn('[skrift-orders] inbound: SKRIFT_WEBHOOK_TOKEN ist nicht gesetzt – Webhook abgelehnt.');
|
||||
return res.status(401).json({ error: 'Webhook nicht konfiguriert (SKRIFT_WEBHOOK_TOKEN fehlt serverseitig).' });
|
||||
}
|
||||
if (String(provided) !== secret) {
|
||||
return res.status(401).json({ error: 'Ungültiger oder fehlender Webhook-Token.' });
|
||||
}
|
||||
if (!['muster', 'anfrage'].includes(kind)) return res.status(404).json({ error: 'Unbekannter Hook (muster|anfrage).' });
|
||||
|
||||
const name = String(body.name || '').trim();
|
||||
const email = norm(body.email);
|
||||
if (!name) return res.status(400).json({ error: 'Name fehlt.', felder: Object.keys(body) });
|
||||
if (!isEmail(email)) return res.status(400).json({ error: 'Gültige E-Mail-Adresse fehlt.', felder: Object.keys(body) });
|
||||
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||
const { settings } = await loadPricingContext(schema);
|
||||
const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
|
||||
|
||||
const customer = (await new UsersService({ schema, accountability: null }).readByQuery({
|
||||
filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
|
||||
}))?.[0];
|
||||
|
||||
const phone = clean(String(body.telefon || body.phone || '').trim()) || null;
|
||||
|
||||
let record; let subject; let mailHtml;
|
||||
if (kind === 'muster') {
|
||||
const produkt = body.produkt_interesse ? clean(String(body.produkt_interesse)) : null;
|
||||
record = {
|
||||
type: 'muster', name: clean(name), email, phone, product: produkt, message: null,
|
||||
address: {
|
||||
street: clean(String(body.street || '')),
|
||||
zip: clean(String(body.zipcode || body.zip || '')),
|
||||
city: clean(String(body.city || '')),
|
||||
country: clean(String(body.country || '')),
|
||||
},
|
||||
status: 'neu', customer: customer?.id ?? null,
|
||||
};
|
||||
subject = `Musteranfrage (Web): ${produkt || '—'} – ${name}`;
|
||||
mailHtml =
|
||||
`<p><strong>${clean(name)}</strong> (${email}${phone ? `, ${phone}` : ''})</p>` +
|
||||
`<p>Interesse: <strong>${produkt || 'k. A.'}</strong></p>` +
|
||||
`<p>${clean(String(body.street || ''))}, ${clean(String(body.zipcode || body.zip || ''))} ${clean(String(body.city || ''))}, ${clean(String(body.country || ''))}</p>`;
|
||||
} else {
|
||||
const unternehmen = clean(String(body.unternehmen || '').trim());
|
||||
const nachricht = clean(String(body.nachricht || body.message || '').trim());
|
||||
// offer_requests hat kein Firmenfeld → Unternehmen der Nachricht voranstellen.
|
||||
const message = [unternehmen ? `Unternehmen: ${unternehmen}` : '', nachricht].filter(Boolean).join('\n\n') || null;
|
||||
record = {
|
||||
type: 'angebot', name: clean(name), email, phone, product: null, message,
|
||||
status: 'neu', customer: customer?.id ?? null,
|
||||
};
|
||||
subject = `Angebotsanfrage (Web) von ${name}`;
|
||||
mailHtml =
|
||||
`<p><strong>${clean(name)}</strong>${unternehmen ? ` · ${unternehmen}` : ''} (${email}${phone ? `, ${phone}` : ''})</p>` +
|
||||
`<p style="white-space:pre-wrap">${nachricht || '—'}</p>`;
|
||||
}
|
||||
|
||||
await svc('offer_requests').createOne(record);
|
||||
logger.info(`[skrift-orders] inbound/${kind}: Anfrage von ${email} gespeichert.`);
|
||||
|
||||
// Interne Team-Benachrichtigung (Kunden-Autoresponder macht JetFormBuilder).
|
||||
try {
|
||||
const mail = new MailService({ schema, accountability: null });
|
||||
const von = await absender(schema);
|
||||
await mail.send({ ...(von ? { from: von } : {}), to: TEAM_EMAIL, subject, html: mailHtml });
|
||||
} catch (mailErr) {
|
||||
logger.warn(`[skrift-orders] Webhook-Mail nicht versendet: ${mailErr.message}`);
|
||||
}
|
||||
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-orders] inbound/${kind}: ${err.stack || err.message}`);
|
||||
return res.status(500).json({ error: 'Anfrage konnte nicht gespeichert werden.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Manueller Auftrag (Admin) ────────────────────────────────────────────
|
||||
// Legt intern einen Auftrag an (keine Zahlung, keine Datei-Eingabe), erzeugt
|
||||
// die SVGs (Vorschau 1:1) und stellt ihn als „eingegangen" ins Board. Nutzt
|
||||
// dieselben Bausteine wie die öffentliche Anlage – keine Doppellogik.
|
||||
router.post('/manual-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 || {};
|
||||
const entries = Array.isArray(body.entries) ? body.entries : [];
|
||||
if (!body.product) return res.status(400).json({ error: 'Produkt fehlt.' });
|
||||
if (!entries.length) return res.status(400).json({ error: 'Mindestens eine Empfängerzeile erforderlich.' });
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||
const { settings } = await loadPricingContext(schema);
|
||||
const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
|
||||
|
||||
const product = (await svc('products').readByQuery({
|
||||
filter: { key: { _eq: body.product } }, limit: 1, fields: ['id', 'key', 'name', 'type'],
|
||||
}))?.[0];
|
||||
if (!product) return res.status(400).json({ error: 'Unbekanntes Produkt.' });
|
||||
|
||||
// Preis nur zur Aufzeichnung (kein Gate).
|
||||
let quote = null;
|
||||
try { quote = await calculatePrice(schema, { ...body, quantity: entries.length }); } catch { /* egal */ }
|
||||
|
||||
// Kunde optional verknüpfen (per E-Mail oder ID).
|
||||
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);
|
||||
}
|
||||
|
||||
let formatId = null;
|
||||
if (body.format) {
|
||||
formatId = (await svc('formats').readByQuery({ filter: { key: { _eq: body.format } }, limit: 1, fields: ['id'] }))?.[0]?.id ?? null;
|
||||
}
|
||||
const envelopeFormat = body.needs_envelope
|
||||
? (body.envelope_format || (String(body.format || '').startsWith('a4') ? 'dinlang' : 'c6'))
|
||||
: null;
|
||||
|
||||
const istInland = (e) => !e.country || /^(de|deutschland|germany)$/i.test(String(e.country).trim());
|
||||
const countAusland = entries.filter((e) => !istInland(e)).length;
|
||||
|
||||
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, format: formatId,
|
||||
person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
|
||||
font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda',
|
||||
source: 'operator', status: 'in_queue', production_status: 'eingegangen',
|
||||
payment_status: 'bezahlt', payment_method: 'rechnung',
|
||||
shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
|
||||
needs_envelope: !!body.needs_envelope,
|
||||
envelope_labeling: body.envelope_labeling ?? null,
|
||||
envelope_text: clean(body.envelope_text) ?? null,
|
||||
envelope_format: envelopeFormat,
|
||||
text_template: clean(body.text_template) ?? null,
|
||||
preview_session: typeof body.preview_session === 'string' ? body.preview_session : null,
|
||||
entries_count: entries.length,
|
||||
count_inland: entries.length - countAusland, count_ausland: countAusland,
|
||||
net_total: quote?.net_total ?? null, vat_amount: quote?.vat_amount ?? null, gross_total: quote?.gross_total ?? null,
|
||||
};
|
||||
|
||||
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');
|
||||
let n = 0;
|
||||
for (const e of entries) {
|
||||
n += 1;
|
||||
await entriesSvc.createOne({
|
||||
order: orderId, letter_number: n,
|
||||
salutation: clean(e.salutation) ?? null, first_name: clean(e.first_name) ?? null, last_name: clean(e.last_name) ?? null,
|
||||
street: clean(e.street) ?? null, house_no: clean(e.house_no) ?? null, zip: clean(e.zip) ?? null,
|
||||
city: clean(e.city) ?? null, country: clean(e.country) ?? null, free_text: clean(e.free_text) ?? null,
|
||||
placeholders: e.placeholders && typeof e.placeholders === 'object'
|
||||
? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))])) : null,
|
||||
});
|
||||
}
|
||||
|
||||
await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Manuell im Admin angelegt.' });
|
||||
|
||||
if (product.type === 'letter' || product.type === 'postcard') {
|
||||
fetch(`${BACKEND_URL}/api/order/from-directus`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
|
||||
body: JSON.stringify({ orderId }),
|
||||
}).catch((e) => logger.warn(`[skrift-orders] Manuell-Generierung nicht gestartet: ${e.message}`));
|
||||
}
|
||||
|
||||
return res.json({ ok: true, order_number: orderNumber, id: orderId });
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-orders] manual-order: ${err.stack || err.message}`);
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Auftrag anlegen ──────────────────────────────────────────────────────
|
||||
router.post('/', async (req, res) => {
|
||||
const body = req.body || {};
|
||||
@@ -1114,6 +1419,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
|
||||
needs_envelope: !!body.needs_envelope,
|
||||
envelope_labeling: body.envelope_labeling ?? null,
|
||||
envelope_text: clean(body.envelope_text) ?? null,
|
||||
envelope_format: envelopeFormat,
|
||||
motif_mode: body.motif_mode ?? null,
|
||||
motif: Number.isFinite(Number(body.motif)) && body.motif ? Number(body.motif) : null,
|
||||
|
||||
Reference in New Issue
Block a user