From 6232cecdb185cc0669df64aeb7b06a0bf8a75a97 Mon Sep 17 00:00:00 2001 From: Lucas Orth Date: Sun, 26 Jul 2026 11:51:24 +0200 Subject: [PATCH] Produktion: Format-Warteschlangen, Sammeldruck, Webhook, Preis-a/e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Docker/directus/.env.example | 4 + Docker/directus/bootstrap/bootstrap.mjs | 50 ++- Docker/directus/docker-compose.yml | 4 + .../dist/index.js | 351 ++++++++++++++++++ .../package.json | 13 + .../dist/index.js | 334 ++++++++++++++++- .../dist/index.js | 238 +++++++++++- .../api/controllers/directus-controller.js | 5 +- Docker/skrift-backend/src/lib/page-layout.js | 21 ++ Docker/skrift-backend/src/server.js | 12 +- .../src/services/directus-service.js | 2 +- Produktion/skrift-agent/agent.py | 141 +++++-- Webseite/skrift-app/src/lib/excel.ts | 15 +- .../skrift-app/src/routes/Configurator.tsx | 125 ++++++- Webseite/skrift-app/src/styles/global.css | 11 +- Webseite/skrift-preisrechner.zip | Bin 8621 -> 8802 bytes .../skrift-preisrechner.php | 10 +- 17 files changed, 1239 insertions(+), 97 deletions(-) create mode 100644 Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js create mode 100644 Docker/directus/extensions/directus-extension-skrift-neuauftrag/package.json diff --git a/Docker/directus/.env.example b/Docker/directus/.env.example index 67025ad..9f2de30 100644 --- a/Docker/directus/.env.example +++ b/Docker/directus/.env.example @@ -42,6 +42,10 @@ SKRIFT_BACKEND_TOKEN=CHANGE-ME-gleich-wie-backend-API_TOKEN SKRIFT_PREVIEW_LIMIT=10 # Zieladresse für Angebots-/Musteranfragen SKRIFT_TEAM_EMAIL=hello@skrift.de +# Shared Secret für den WordPress-Webhook (JetFormBuilder → /skrift-orders/inbound/*). +# Frei wählbarer langer Zufallswert; muss identisch als ?token=... in der +# Webhook-URL stehen. Ohne diesen Wert lehnt der Webhook alle Anfragen ab. +SKRIFT_WEBHOOK_TOKEN=CHANGE-ME-langer-zufallswert # ── PayPal (server-seitige Zahlungsprüfung) ───────────────── # Client-ID ist öffentlich (auch im Frontend), das Secret bleibt NUR hier. diff --git a/Docker/directus/bootstrap/bootstrap.mjs b/Docker/directus/bootstrap/bootstrap.mjs index 8f2b969..a77a60a 100644 --- a/Docker/directus/bootstrap/bootstrap.mjs +++ b/Docker/directus/bootstrap/bootstrap.mjs @@ -156,6 +156,12 @@ const collections = [ // Ab dieser Menge gilt der Normalpreis (Multiplikator 1) – je Produkt. // Leer = Rückfall auf die globale Norm-Menge in den Preis-Einstellungen. f('norm_qty', 'float', { interface: 'input', note: 'Menge, ab der der Normalpreis gilt (Multiplikator 1). Leer = globaler Wert.' }), + // Kleinmengen-Aufschlag je Produkt (fließt als a/e in die Multiplikator-Formel): + // a = Deckel (bei Menge→0 ist der Multiplikator 1 + a), e = Abklingform + // (großes e = Aufschlag fällt schnell; kleines e = bleibt über mittlere Mengen hoch). + // Leer → a = 1, e = 3 (bisheriges Verhalten). Postkarten z. B. a = 1.5, e = 2. + f('price_factor_a', 'float', { interface: 'input', default: 1, note: 'Kleinmengen-Aufschlag: Deckel a (Multiplikator bei Menge→0 = 1 + a). Leer = 1.' }), + f('price_factor_e', 'float', { interface: 'input', default: 3, note: 'Kleinmengen-Aufschlag: Exponent e (größer = fällt schneller ab). Leer = 3.' }), // Eingabeart im Konfigurator: freier Text oder Datei-Upload (z. B. ein zu // unterschreibendes PDF beim Unterschriftenservice). dropdown('input_mode', ['text', 'datei'], { default: 'text', note: 'Text-Eingabe oder Datei-Upload.' }), @@ -195,7 +201,7 @@ const collections = [ // Beispiel: (qty >= norm_b) ? 1 : (2 - sqrt(qty / norm_b)) // Eine gemeinsame Formel für Privat und Unternehmen. `norm` ist die // Normalpreis-Menge des jeweiligen Produkts (products.norm_qty). - f('multiplier_formula', 'text', { interface: 'input-multiline', note: 'Mengen-Multiplikator (gilt für Privat und Unternehmen). Variablen: qty, norm, base_price. Funktionen: sqrt, pow, min, max, round …' }), + f('multiplier_formula', 'text', { interface: 'input-multiline', note: 'Mengen-Multiplikator (gilt für Privat und Unternehmen). Variablen: qty, norm, base_price, a, e (a/e je Produkt). Funktionen: sqrt, pow, min, max, round …' }), f('mwst_percent', 'float', { interface: 'input', default: 19 }), // Mengenrabatt-Staffel: ab min_qty Stück gilt percent % Rabatt auf den // Grundpreis. Es greift die höchste passende Stufe. Frei in Directus pflegbar. @@ -249,6 +255,7 @@ const collections = [ dropdown('shipping_type', ['einzeln', 'sammel'], { default: 'sammel' }), f('needs_envelope', 'boolean', { interface: 'boolean', default: false }), dropdown('envelope_labeling', ['empfaenger', 'freitext', 'keine']), + f('envelope_text', 'text', { interface: 'input-multiline', note: 'Individueller Kuverttext (bei envelope_labeling = freitext).' }), dropdown('envelope_format', ['c6', 'dinlang'], { note: 'Automatisch aus Format.' }), dropdown('motif_mode', ['katalog', 'upload', 'ohne', 'eigene_karten']), m2o('motif', 'motifs'), @@ -334,6 +341,10 @@ const collections = [ f('active', 'boolean', { interface: 'boolean', default: true }), dropdown('status', ['unbekannt', 'bereit', 'druckt', 'fehler', 'offline'], { default: 'unbekannt', note: 'Vom Agent gemeldet.' }), { field: 'last_seen', type: 'timestamp', meta: { interface: 'datetime', note: 'Letzte Rückmeldung des Agents.' }, schema: {} }, + // Aktuell eingelegtes Format: Der Agent druckt NUR Jobs mit passendem + // format-Schlüssel und springt nie selbstständig auf ein anderes Format. + // Leer = Maschine wartet, bis im Board ein Format als eingelegt gewählt wird. + f('current_format', 'string', { interface: 'input', note: 'Aktuell eingelegtes Format (Agent druckt nur passende Jobs). Leer = wartet auf Formatwahl.' }), f('notes', 'text', { interface: 'input-multiline' }), ] }, @@ -391,6 +402,11 @@ const collections = [ m2o('machine', 'machines'), dropdown('type', ['brief', 'umschlag', 'signatur'], { default: 'brief', note: 'Was gedruckt wird.' }), m2o('template', 'format_templates', { note: 'Aufgelöstes Druck-Template.' }), + // Format-Schlüssel des Jobs (Brief: a4/a6…, Kuvert: dinlang/c6). Grundlage + // für die Format-Warteschlange: der Agent druckt nur Jobs, deren Format dem + // an der Maschine eingelegten Format entspricht. + f('format', 'string', { interface: 'input', note: 'Format-Schlüssel (Brief: a4/a6…, Kuvert: dinlang/c6). Grundlage der Format-Warteschlange.' }), + f('format_label', 'string', { interface: 'input', note: 'Lesbare Formatbezeichnung für die Warteschlangen-Ansicht.' }), dropdown('status', ['queued', 'printing', 'printed', 'failed', 'shipped'], { default: 'queued' }), f('priority', 'integer', { interface: 'input', default: 0, note: 'Höher = zuerst. Direktdruck bekommt hohe Priorität.' }), // Pro-Druck-Übersteuerung der Template-Maße (Breite/Höhe/X/Y/Scale/Rot). @@ -593,8 +609,10 @@ async function seedItem(collection, uniqueField, item) { async function applySeed() { console.log('› Seed'); const products = [ - { key: 'briefe', name: 'Briefe', type: 'letter', visibility: 'alle', pricing_mode: 'formel', base_price: 4.0, norm_qty: 100, active: true, sort: 1 }, - { key: 'postkarten', name: 'Postkarten', type: 'postcard', visibility: 'alle', pricing_mode: 'formel', base_price: 1.3, norm_qty: 100, active: true, sort: 2 }, + { key: 'briefe', name: 'Briefe', type: 'letter', visibility: 'alle', pricing_mode: 'formel', base_price: 4.0, norm_qty: 100, price_factor_a: 1, price_factor_e: 3, active: true, sort: 1 }, + // Postkarten: höherer Deckel + sanfterer Exponent, damit kleine/mittlere + // Mengen nicht zu günstig werden (Fixaufwand relativ größer als bei Briefen). + { key: 'postkarten', name: 'Postkarten', type: 'postcard', visibility: 'alle', pricing_mode: 'formel', base_price: 1.3, norm_qty: 100, price_factor_a: 1.5, price_factor_e: 2, active: true, sort: 2 }, { key: 'muster', name: 'Muster', type: 'sample', visibility: 'alle', pricing_mode: 'formel', base_price: 0, active: true, sort: 3 }, // Datei-Produkt: pro Kunde freischaltbar, Kunde lädt ein PDF/Word hoch. // pricing_mode „formel" + base_price, damit es für freigeschaltete Kunden @@ -641,12 +659,30 @@ async function applySeed() { log('seed singleton pricing_settings'); } // Gemeinsame Mengen-Multiplikator-Formel: nur setzen, wenn leer (Edits bleiben - // erhalten). `norm` kommt je Produkt aus products.norm_qty. + // erhalten). `norm` kommt je Produkt aus products.norm_qty, a/e je Produkt. + const CANON_MULT = '(qty >= norm) ? 1 : (1 + a * pow(1 - qty / norm, e))'; if (!ps || !ps.multiplier_formula) { - await api('PATCH', '/items/pricing_settings', { - multiplier_formula: '(qty >= norm) ? 1 : (1 + 2 * pow(1 - qty / norm, 3))', - }); + await api('PATCH', '/items/pricing_settings', { multiplier_formula: CANON_MULT }); log('seed multiplier-formel'); + } else { + // Migration: eine bestehende Formel mit fest verdrahtetem Koeffizient/Exponent + // (1 + N * pow(1 - qty / norm, M)) auf die a/e-Variante umstellen und die + // Altwerte in die Produkte übernehmen – Verhalten bleibt exakt erhalten. + const m = String(ps.multiplier_formula).match( + /^\(\s*qty\s*>=\s*norm\s*\)\s*\?\s*1\s*:\s*\(\s*1\s*\+\s*([\d.]+)\s*\*\s*pow\(\s*1\s*-\s*qty\s*\/\s*norm\s*,\s*([\d.]+)\s*\)\s*\)$/, + ); + if (m) { + const aOld = Number(m[1]); const eOld = Number(m[2]); + await api('PATCH', '/items/pricing_settings', { multiplier_formula: CANON_MULT }); + const prods = await api('GET', '/items/products?fields=id,price_factor_a,price_factor_e&limit=-1'); + for (const p of (prods || [])) { + const patch = {}; + if (p.price_factor_a == null) patch.price_factor_a = aOld; + if (p.price_factor_e == null) patch.price_factor_e = eOld; + if (Object.keys(patch).length) await api('PATCH', `/items/products/${p.id}`, patch); + } + log(`migriere multiplier-formel → a/e (Altwerte a=${aOld}, e=${eOld} in Produkte übernommen)`); + } } // E-Mail-Vorlagen (nur anlegen, wenn noch nicht vorhanden – Edits bleiben erhalten). diff --git a/Docker/directus/docker-compose.yml b/Docker/directus/docker-compose.yml index 1839a3a..e5b57e1 100644 --- a/Docker/directus/docker-compose.yml +++ b/Docker/directus/docker-compose.yml @@ -80,6 +80,10 @@ services: SKRIFT_PREVIEW_LIMIT: ${SKRIFT_PREVIEW_LIMIT:-10} SKRIFT_TEAM_EMAIL: ${SKRIFT_TEAM_EMAIL:-hello@skrift.de} + # Shared Secret für den WordPress-Webhook (JetFormBuilder → /skrift-orders/inbound/*). + # Muss identisch als ?token=... in der Webhook-URL stehen. + SKRIFT_WEBHOOK_TOKEN: ${SKRIFT_WEBHOOK_TOKEN} + # PayPal – server-seitige Zahlungsprüfung (Secret liegt NUR hier, nie im Frontend) PAYPAL_CLIENT_ID: ${PAYPAL_CLIENT_ID} PAYPAL_CLIENT_SECRET: ${PAYPAL_CLIENT_SECRET} diff --git a/Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js new file mode 100644 index 0000000..a2489ec --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js @@ -0,0 +1,351 @@ +/** + * Skrift – Modul „Neuer Auftrag" + * =========================================================================== + * Eigenständige, manuelle Auftragsanlage im Admin (eigenes Modul, kein Teil des + * Produktions-Boards). Nutzt ausschließlich die zentralen Backend-Endpunkte: + * POST /skrift-orders/preview – Vorschau (Admin ohne Kontingent) + * POST /skrift-orders/manual-order – Auftrag intern anlegen (keine Zahlung) + * Der Auftrag erscheint danach als „eingegangen" im Produktions-Board. + * + * Von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit. + */ +import { defineComponent, ref, reactive, onMounted, h, resolveComponent, Teleport } from 'vue'; +import { useApi } from '@directus/extensions-sdk'; + +const ACCENT = '#1E50A5'; +// Weißes, randloses Eingabefeld – hebt sich durch Kontrast + leichten Schatten +// vom hellblauen Bereich ab (kein Rahmen). +const WFELD = 'background:#fff;border:none;border-radius:8px;padding:9px 12px;font:inherit;font-size:14px;color:#1A1A1A;outline:none;box-shadow:0 1px 2px rgba(0,0,0,.08);'; +const WSELECT = WFELD + 'cursor:pointer;'; +// Hellblauer Bereich (kein Rahmen). +const BEREICH = 'background:rgba(30,80,165,.06);border-radius:12px;padding:18px 20px;'; +const STANDARD_PLATZHALTER = ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land', 'Briefnummer']; + +function btn(label, onClick, kind) { + const base = 'cursor:pointer;border-radius:8px;padding:8px 16px;font:inherit;font-size:14px;border:none;'; + const style = kind === 'primary' ? base + `background:${ACCENT};color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.15);` + : base + `background:#fff;color:${ACCENT};box-shadow:0 1px 2px rgba(0,0,0,.1);`; + return h('button', { type: 'button', onClick, style }, label); +} +function subdued(t, extra) { return h('div', { style: `color:var(--foreground-subdued);font-size:13px;${extra || ''}` }, t); } +function segToggle(value, opts, onSel) { + return h('div', { style: 'display:flex;width:100%;border-radius:8px;overflow:hidden;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.08);' }, + opts.map((o) => h('button', { + type: 'button', onClick: () => onSel(o.key), + style: `flex:1;padding:10px 8px;font:inherit;font-size:13px;text-align:center;cursor:pointer;border:none;` + + (value === o.key ? `background:${ACCENT};color:#fff;font-weight:700;` : `background:transparent;color:${ACCENT};`), + }, o.label))); +} +function bereichKopf(txt) { return h('div', { style: `font-size:15px;font-weight:800;color:${ACCENT};margin-bottom:14px;` }, txt); } +function label(txt) { return h('label', { style: 'display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);margin-bottom:4px;' }, txt); } + +const NeuAuftrag = defineComponent({ + setup() { + const api = useApi(); + const produkte = ref([]); + const formate = ref([]); + const verbotenRe = ref(null); + const musterTexte = ref({ text_du: '', text_sie: '' }); + const geladen = ref(false); + + const leereZeile = () => ({ free_text: '', placeholders: {} }); + const neu = reactive({ + product: '', format: '', font: 'tilda', + // Kuvert-Adresse je Empfänger: '5zeilen' (5 Zeilen) oder 'individuell' (einzeilig). + adressModus: '5zeilen', kuverts: false, realistic: true, customer_email: '', text: '', rows: [leereZeile()], + importOpen: false, importText: '', + docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '', + }); + // Kuverts nur generieren, wenn angehakt (Beschriftung aus der Empfängeradresse). + const kuvertPayload = () => (neu.kuverts + ? { needs_envelope: true, envelope_labeling: 'empfaenger', envelope_text: '' } + : { needs_envelope: false, envelope_labeling: 'keine', envelope_text: '' }); + + async function laden() { + try { + const [p, f, ps, mt] = await Promise.all([ + api.get('/items/products', { params: { limit: -1, filter: { active: { _eq: true }, type: { _in: ['letter', 'postcard'] } }, fields: 'key,name,type' } }), + api.get('/items/formats', { params: { limit: -1, filter: { active: { _eq: true } }, fields: 'key,name,product.key' } }), + api.get('/items/pricing_settings', { params: { fields: 'char_whitelist' } }).catch(() => ({ data: { data: {} } })), + api.get('/items/muster_settings', { params: { fields: 'text_du,text_sie' } }).catch(() => ({ data: { data: {} } })), + ]); + produkte.value = p.data?.data || []; + formate.value = f.data?.data || []; + musterTexte.value = mt.data?.data || { text_du: '', text_sie: '' }; + const wl = ps.data?.data?.char_whitelist; + // Zeilenumbrüche immer erlauben (Brieftext ist mehrzeilig). Einzeilige + // Felder entfernen \n/\r ohnehin separat, daher hier gefahrlos. + try { verbotenRe.value = wl ? new RegExp(`[^${wl}\\n\\r]`, 'g') : null; } catch { verbotenRe.value = null; } + neu.product = produkte.value[0]?.key || 'briefe'; + neu.format = neuFormate()[0]?.key || ''; + } catch (e) { + neu.err = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.'; + } finally { geladen.value = true; } + } + onMounted(laden); + + const saeubere = (v) => { + if (!verbotenRe.value) return v; + const treffer = String(v).match(verbotenRe.value); + if (!treffer || !treffer.length) return v; + const einmalig = [...new Set(treffer)].map((c) => (c === ' ' ? '␣' : c)).join(' '); + neu.hinweis = `Zeichen ${einmalig} ist nicht erlaubt und wurde entfernt.`; + setTimeout(() => { if (neu.hinweis.includes(einmalig)) neu.hinweis = ''; }, 4000); + return String(v).replace(verbotenRe.value, ''); + }; + // Alle aktiven Formate zur Auswahl (nicht aufs Produkt eingeschränkt) – + // so lassen sich auch freie/eigene Formate direkt wählen. + const neuFormate = () => formate.value; + // Wie viele Adresszeilen je Empfänger: 5 („5 Zeilen") bzw. 1 („Individueller Text"). + const zeilenAnzahl = () => (neu.adressModus === 'individuell' ? 1 : 5); + const freitext = (e, z) => (String(e.free_text || '').split('\n')[z] ?? ''); + const setFreitext = (i, z, v) => { + const max = zeilenAnzahl(); + const zeilen = String(neu.rows[i].free_text || '').split('\n'); + while (zeilen.length < max) zeilen.push(''); + zeilen[z] = saeubere(String(v).replace(/[\r\n]/g, '')); + neu.rows[i].free_text = zeilen.slice(0, max).join('\n').replace(/\n+$/, ''); + neu.previewSession = null; + }; + // Individuelle Platzhalter je Empfänger (z. B. [[Gutschein]]). + const setPlatzhalter = (i, name, v) => { + neu.rows[i].placeholders = { ...(neu.rows[i].placeholders || {}), [name]: saeubere(v) }; + neu.previewSession = null; + }; + // Platzhalter aus dem Brieftext ziehen (ohne den automatischen [[Briefnummer]]). + const customPlaceholders = () => { + const set = new Set(); + for (const m of String(neu.text || '').matchAll(/\[\[([^\]]+)\]\]/g)) { + const name = m[1].trim(); + if (name && name.toLowerCase() !== 'briefnummer') set.add(name); + } + return [...set]; + }; + const addZeile = () => neu.rows.push(leereZeile()); + const delZeile = (i) => { neu.rows.splice(i, 1); if (!neu.rows.length) neu.rows.push(leereZeile()); }; + // Empfänger zählt, wenn eine Adresszeile ODER ein Platzhalterwert gefüllt ist. + const entries = () => neu.rows.filter((e) => String(e.free_text || '').trim() + || (e.placeholders && Object.values(e.placeholders).some((v) => String(v || '').trim()))); + + // Aus Excel/CSV eingefügte Zeilen übernehmen. Erst die individuellen + // Platzhalter-Spalten, dann die Adresszeilen (Tab/Semikolon/Komma-getrennt). + function importUebernehmen() { + const ph = customPlaceholders(); + const max = zeilenAnzahl(); + const zeilen = String(neu.importText || '').split(/\r?\n/).map((z) => z.trim()).filter(Boolean); + if (!zeilen.length) { neu.importOpen = false; return; } + const rows = zeilen.map((z) => { + const zellen = z.split(/\t|;|,/).map((c) => saeubere(c.trim())); + const row = leereZeile(); + ph.forEach((name, i) => { row.placeholders = { ...row.placeholders, [name]: zellen[i] || '' }; }); + row.free_text = zellen.slice(ph.length, ph.length + max).join('\n'); + return row; + }); + neu.rows = rows; + neu.importText = ''; neu.importOpen = false; neu.previewSession = null; + } + + async function preview() { + // Vorschau ohne Empfänger erlaubt – das Backend nutzt dann eine Blankozeile. + if (!neu.text.trim()) { neu.err = 'Bitte zuerst einen Text eingeben.'; return; } + neu.pLaedt = true; neu.err = ''; neu.msg = ''; + try { + const kv = kuvertPayload(); + const res = await api.post('/skrift-orders/preview', { + text: neu.text, font: neu.font, format: neu.format, realistic: neu.realistic, + envelope_labeling: kv.envelope_labeling, envelope_text: kv.envelope_text, + entries: entries().slice(0, 3), + }); + const d = res.data || {}; + if (d.has_overflow) { neu.err = 'Text ist zu lang für das Format – bitte kürzen.'; neu.docs = []; neu.previewSession = null; } + else { neu.docs = d.docs || d.previews || []; neu.previewSession = d.session || d.sessionId || null; } + } catch (e) { neu.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Vorschau fehlgeschlagen.'; } + finally { neu.pLaedt = false; } + } + async function absenden() { + // Empfänger sind optional – ohne Empfänger genau ein Schriftstück (Blankozeile). + const ent = entries(); + const payloadEntries = ent.length ? ent : [leereZeile()]; + neu.err = ''; neu.msg = 'Wird angelegt …'; + try { + const kv = kuvertPayload(); + const res = await api.post('/skrift-orders/manual-order', { + product: neu.product, format: neu.format, font: neu.font, + needs_envelope: kv.needs_envelope, envelope_labeling: kv.envelope_labeling, envelope_text: kv.envelope_text, + shipping_type: 'sammel', + customer_email: neu.customer_email.trim() || undefined, + text_template: neu.text, preview_session: neu.previewSession || undefined, + entries: payloadEntries, + }); + const nr = res.data?.order_number || ''; + // Felder bewusst NICHT zurücksetzen – der letzte Auftrag bleibt stehen, + // damit er sich als Vorlage für den nächsten leicht anpassen lässt. + neu.msg = `✓ Auftrag ${nr} angelegt – liegt im Produktions-Board unter „Neu". Felder bleiben stehen; mit „Formular leeren" zurücksetzen.`; + } catch (e) { neu.err = e?.response?.data?.error || e?.message || 'Anlegen fehlgeschlagen.'; neu.msg = ''; } + } + function zuruecksetzen() { + Object.assign(neu, { + product: produkte.value[0]?.key || 'briefe', format: neuFormate()[0]?.key || '', font: 'tilda', + adressModus: '5zeilen', kuverts: false, realistic: true, customer_email: '', text: '', rows: [leereZeile()], + importOpen: false, importText: '', + docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '', + }); + } + const musterEinfuegen = (form) => { + const t = form === 'du' ? musterTexte.value.text_du : musterTexte.value.text_sie; + if (!t) return; + neu.text = neu.text.trim() ? `${neu.text}\n\n${t}` : t; + neu.previewSession = null; + }; + + // Weißes Dropdown / Eingabefeld. + const sel = (val, opts, on, extra) => h('select', { style: WSELECT + 'width:100%;' + (extra || ''), value: val, onChange: on }, + opts.map((o) => h('option', { value: o.v, selected: String(val) === String(o.v) }, o.t))); + const svgUri = (svg) => { try { return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); } catch { return null; } }; + const docBild = (d) => { + const svg = d && typeof d.svg === 'string' ? d.svg : null; + const src = svg ? svgUri(svg) : (typeof d === 'string' ? d : (d?.dataUrl || d?.url || d?.image || null)); + const txt = (d?.art === 'umschlag' ? '✉ Kuvert' : '📄 Schriftstück'); + return h('div', { style: 'display:flex;flex-direction:column;align-items:center;gap:4px;' }, [ + src ? h('img', { src, onClick: () => { neu.lb = src; }, style: 'width:100%;max-height:260px;object-fit:contain;border-radius:6px;background:#fff;cursor:zoom-in;box-shadow:0 1px 3px rgba(0,0,0,.12);' }) + : h('div', { style: 'width:100%;height:200px;border-radius:6px;display:flex;align-items:center;justify-content:center;background:#fff;color:var(--foreground-subdued);' }, 'Dok.'), + h('span', { style: 'font-size:11px;color:var(--foreground-subdued);' }, txt), + ]); + }; + // Weißes, einzeiliges Zellenfeld (feste Breite → Zeile scrollt bei Bedarf). + const feld = (lbl, val, on, ph) => h('div', { style: 'display:flex;flex-direction:column;gap:3px;flex:0 0 150px;' }, [ + h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);' }, lbl), + h('input', { value: val, placeholder: ph || '', style: WFELD + 'width:100%;padding:7px 9px;font-size:13px;', onInput: on }), + ]); + + function formular() { + // Empfängerzeile: erst individuelle Platzhalter, dann die Adresszeilen. + const zeile = (e, i) => [ + ...customPlaceholders().map((name) => feld(name, e.placeholders?.[name] || '', (ev) => setPlatzhalter(i, name, ev.target.value))), + ...Array.from({ length: zeilenAnzahl() }, (_, z) => feld('Zeile ' + (z + 1), freitext(e, z), (ev) => setFreitext(i, z, ev.target.value))), + ]; + + // Bereich 1 – Produkt + const b1 = h('div', { style: BEREICH }, [ + bereichKopf('📄 Produkt'), + h('div', { style: 'display:grid;grid-template-columns:repeat(3,1fr);gap:12px;' }, [ + h('div', {}, [label('Produkt'), sel(neu.product, produkte.value.map((p) => ({ v: p.key, t: p.name })), (e) => { neu.product = e.target.value; neu.format = neuFormate()[0]?.key || ''; neu.previewSession = null; })]), + h('div', {}, [label('Format'), sel(neu.format, neuFormate().map((f) => ({ v: f.key, t: f.name })), (e) => { neu.format = e.target.value; neu.previewSession = null; })]), + h('div', {}, [label('Schrift'), sel(neu.font, [['tilda', 'Tilda'], ['alva', 'Alva'], ['ellie', 'Ellie']].map(([v, t]) => ({ v, t })), (e) => { neu.font = e.target.value; neu.previewSession = null; })]), + ]), + h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;margin-top:12px;cursor:pointer;' }, [ + h('input', { type: 'checkbox', checked: neu.realistic, onChange: (e) => { neu.realistic = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }), + 'Natürliche Handschrift (leichte Variation)', + ]), + ]); + + // Bereich 2 – Kuvert (Empfängertabelle) + const b2 = h('div', { style: BEREICH }, [ + h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;' }, [ + bereichKopf('✉ Kuvert'), + h('div', { style: 'width:300px;' }, [segToggle(neu.adressModus, [{ key: '5zeilen', label: '5 Zeilen' }, { key: 'individuell', label: 'Individueller Text' }], (k) => { neu.adressModus = k; neu.previewSession = null; })]), + ]), + h('div', { style: 'display:flex;flex-direction:column;gap:8px;max-height:34vh;overflow:auto;' }, + neu.rows.map((e, i) => h('div', { style: 'display:flex;gap:8px;align-items:center;' }, [ + h('span', { style: `min-width:22px;font-weight:700;color:${ACCENT};` }, `#${i + 1}`), + h('div', { style: 'flex:1;min-width:0;display:flex;gap:6px;flex-wrap:nowrap;overflow-x:auto;padding-bottom:2px;' }, zeile(e, i)), + h('button', { type: 'button', onClick: () => delZeile(i), style: 'cursor:pointer;border:none;background:transparent;color:var(--danger,#c62828);font-size:18px;padding:2px 6px;' }, '×'), + ]))), + h('div', { style: 'margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:12px;' }, [ + btn('+ Empfänger', addZeile), + btn(neu.importOpen ? 'Import schließen' : '⇪ Aus Excel einfügen', () => { neu.importOpen = !neu.importOpen; }), + ]), + h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;margin-top:12px;' }, [ + h('input', { type: 'checkbox', checked: neu.kuverts, onChange: (e) => { neu.kuverts = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }), + 'Kuverts generieren', + ]), + neu.importOpen ? h('div', { style: 'margin-top:12px;' }, [ + subdued('Aus Excel kopieren und einfügen – eine Zeile pro Empfänger, Spalten mit Tab/Semikolon/Komma. ' + + 'Reihenfolge: erst individuelle Platzhalter, dann ' + (neu.adressModus === 'individuell' ? 'die eine Adresszeile.' : 'Zeile 1–5.'), 'margin-bottom:6px;'), + h('textarea', { value: neu.importText, rows: 5, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.importText = e.target.value; } }), + h('div', { style: 'margin-top:6px;' }, [btn('Übernehmen', importUebernehmen, 'primary')]), + ]) : null, + ]); + + // Bereich 3 – Text (Mustertext-Dropdown direkt darüber) + const b3 = h('div', { style: BEREICH }, [ + h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;' }, [ + bereichKopf('✍ Text'), + h('select', { style: WSELECT + 'font-size:13px;padding:7px 10px;width:auto;', onChange: (e) => { if (e.target.value) { musterEinfuegen(e.target.value); e.target.value = ''; } } }, [ + h('option', { value: '' }, 'Mustertext einfügen …'), + h('option', { value: 'sie' }, 'Sie-Form'), + h('option', { value: 'du' }, 'Du-Form'), + ]), + ]), + label(`Brieftext · ${neu.text.length} Zeichen · [[Platzhalter]] erlaubt`), + h('textarea', { value: neu.text, rows: 8, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.text = saeubere(e.target.value); neu.previewSession = null; } }), + ]); + + // Bereich 4 – Kunde + const b4 = h('div', { style: BEREICH }, [ + bereichKopf('👤 Kunde'), + h('div', { style: 'max-width:420px;' }, [ + label('Kunde verknüpfen (E-Mail, optional)'), + h('input', { type: 'email', value: neu.customer_email, placeholder: 'kunde@example.de', style: WFELD + 'width:100%;', onInput: (e) => { neu.customer_email = e.target.value; } }), + ]), + ]); + + const aktionen = h('div', { class: 'sk-btnrow', style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [ + neu.hinweis ? h('div', { style: `color:var(--danger,#c62828);font-size:13px;width:100%;` }, neu.hinweis) : null, + neu.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;width:100%;' }, neu.err) : null, + neu.msg ? h('div', { style: 'width:100%;' }, subdued(neu.msg)) : null, + btn(neu.pLaedt ? 'Vorschau …' : '👁 Vorschau', preview), + btn('In Druck geben', absenden, 'primary'), + btn('Formular leeren', zuruecksetzen), + ]); + + const links = h('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:16px;' }, [b1, b2, b3, b4, aktionen]); + + // Vorschau rechts daneben (eigener hellblauer Bereich). + const rechts = h('div', { style: BEREICH + 'flex:0 0 340px;position:sticky;top:20px;align-self:flex-start;' }, [ + h('div', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;' }, [ + bereichKopf('👁 Vorschau'), + btn(neu.pLaedt ? '…' : 'Aktualisieren', preview), + ]), + neu.pLaedt ? subdued('Wird erzeugt …') + : neu.docs.length ? h('div', { style: 'display:flex;flex-direction:column;gap:12px;' }, neu.docs.slice(0, 4).map(docBild)) + : subdued('Noch keine Vorschau. „Aktualisieren" klicken (Klick aufs Bild = groß).'), + ]); + + return h('div', { style: 'display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;' }, [links, rechts]); + } + + // Linke Navigationsspalte: aktuelle Werkzeuge dieses Moduls. Der + // Unterschriftenservice folgt als weiterer Eintrag. + const navItem = (label, aktiv, disabled) => h('div', { + style: 'display:flex;align-items:center;gap:8px;border-radius:6px;padding:10px 12px;margin-bottom:3px;font-size:14px;' + + (aktiv ? `background:${ACCENT};color:#fff;font-weight:600;` : disabled ? 'color:var(--foreground-subdued);opacity:.7;' : 'color:var(--foreground-normal);'), + }, label); + + return () => h(resolveComponent('private-view'), { title: 'Neuer Auftrag' }, { + navigation: () => h('div', { style: 'padding:12px 10px;' }, [ + navItem('📝 Neuer Auftrag', true, false), + navItem('✍ Unterschriftenservice (folgt)', false, true), + ]), + default: () => [ + // Mobil: Button-Reihen bleiben in einer Zeile und füllen die Breite. + h('style', {}, '@media(max-width:640px){.sk-btnrow>button{flex:1 1 auto!important;}}'), + h('div', { style: 'padding:24px 28px;background:var(--background-subdued,#f1f4f9);min-height:100%;' }, + !geladen.value ? subdued('Lädt …') : formular()), + neu.lb ? h(Teleport, { to: 'body' }, [ + h('div', { + onClick: () => { neu.lb = ''; }, + style: 'position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1100;display:flex;align-items:center;justify-content:center;padding:4vh 4vw;cursor:zoom-out;', + }, [h('img', { src: neu.lb, style: 'max-width:100%;max-height:90vh;object-fit:contain;background:#fff;border-radius:8px;' })]), + ]) : null, + ], + }); + }, +}); + +export default { + id: 'skrift-neuauftrag', + name: 'Manueller Druck', + icon: 'print', + routes: [{ path: '', component: NeuAuftrag }], +}; diff --git a/Docker/directus/extensions/directus-extension-skrift-neuauftrag/package.json b/Docker/directus/extensions/directus-extension-skrift-neuauftrag/package.json new file mode 100644 index 0000000..511a115 --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-neuauftrag/package.json @@ -0,0 +1,13 @@ +{ + "name": "directus-extension-skrift-neuauftrag", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Modul: Neuer Auftrag – manuelle Auftragsanlage (Format/Kuvert/Schrift, Struktur- oder 5-Zeilen-Adressen, Vorschau) über die zentralen Backend-Endpunkte.", + "directus:extension": { + "type": "module", + "path": "dist/index.js", + "source": "dist/index.js", + "host": "^11.0.0" + } +} diff --git a/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js index 1f6ce76..a728b68 100644 --- a/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js @@ -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:{ '': { 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 = + `

${clean(name)} (${email}${phone ? `, ${phone}` : ''})

` + + `

Interesse: ${produkt || 'k. A.'}

` + + `

${clean(String(body.street || ''))}, ${clean(String(body.zipcode || body.zip || ''))} ${clean(String(body.city || ''))}, ${clean(String(body.country || ''))}

`; + } 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 = + `

${clean(name)}${unternehmen ? ` · ${unternehmen}` : ''} (${email}${phone ? `, ${phone}` : ''})

` + + `

${nachricht || '—'}

`; + } + + 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, diff --git a/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js index b637741..9170bef 100644 --- a/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js @@ -27,6 +27,8 @@ const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6l_v1' }; // v1 = Empfängeradresse (Standardvorschlag). Für individuelle Beschriftung // wählt der Operator im Popup die Variante 2 (kuvert_a_v2 / kuvert_b_v2). const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' }; +// Lesbare Kuvert-Formatlabels (für Sammel-Druck + Format-Warteschlange). +const ENV_LABEL = { dinlang: 'DIN Lang', c6: 'C6' }; const INLAND = /^(de|deutschland|germany)$/i; const TABS = [ @@ -136,6 +138,9 @@ const Board = defineComponent({ const manuell = reactive({ open: false, req: null, form: 'sie', font: 'tilda', format: 'a4', template: null, machine: null, mode: 'queue', text: '', msg: '', err: '' }); const empf = reactive({ open: false, mode: 'order', titel: '', order: null, suche: '', rows: [], files: [], info: '', laden: false, selected: null, pl: '', pe: '', pLoad: false, perr: '', lb: '', lbTitel: '' }); + // Massenauswahl (Auftrags-IDs) + Sammel-Druck-Popup (pro Format Template+Maschine). + const auswahl = ref([]); + const bulk = reactive({ open: false, mode: 'queue', gruppen: [], msg: '', err: '' }); // ── Laden ── // Basis-Felder ohne die evtl. noch nicht gebootstrapten Felder – diese @@ -153,6 +158,24 @@ const Board = defineComponent({ return api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER } }); } } + // Jobs mit Format-Feldern; fällt ohne sie zurück, falls der Bootstrap für die + // Format-Warteschlange noch nicht gelaufen ist (dann degradiert die Ansicht). + const JOB_FELDER = 'id,type,status,priority,numbers,output_ref,error,date_created,machine.id,machine.name,order.id,order.order_number'; + async function ladeJobs() { + const filter = { status: { _in: ['queued', 'printing', 'failed'] } }; + try { + return await api.get('/items/jobs', { params: { limit: -1, sort: '-priority,date_created', filter, fields: JOB_FELDER + ',format,format_label' } }); + } catch { + return api.get('/items/jobs', { params: { limit: -1, sort: '-priority,date_created', filter, fields: JOB_FELDER } }); + } + } + async function ladeMachines() { + try { + return await api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen,current_format' } }); + } catch { + return api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen' } }); + } + } async function laden() { loading.value = true; fehler.value = ''; try { @@ -163,12 +186,8 @@ const Board = defineComponent({ filter: { status: { _in: ['neu', 'in_bearbeitung'] } }, fields: 'id,type,name,email,phone,quantity,product,message,address,status,date_created', } }), - api.get('/items/jobs', { params: { - limit: -1, sort: '-priority,date_created', - filter: { status: { _in: ['queued', 'printing', 'failed'] } }, - fields: 'id,type,status,priority,numbers,output_ref,error,date_created,machine.id,machine.name,order.id,order.order_number', - } }), - api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen' } }), + ladeJobs(), + ladeMachines(), api.get('/items/format_templates', { params: { limit: -1, fields: 'id,key,name,type,width,height,xpos,ypos,scale,rotation' } }), api.get('/items/muster_settings', { params: { fields: 'text_du,text_sie' } }).catch(() => ({ data: { data: { text_du: '', text_sie: '' } } })), ]); @@ -251,6 +270,101 @@ const Board = defineComponent({ } catch (e) { fehler.value = e?.message || 'Statuswechsel fehlgeschlagen.'; } } + // ── Massenauswahl + Sammel-Druck ───────────────────────────────────────── + const istGewaehlt = (id) => auswahl.value.includes(String(id)); + function toggleAuswahl(id) { + id = String(id); + auswahl.value = istGewaehlt(id) ? auswahl.value.filter((x) => x !== id) : [...auswahl.value, id]; + } + const auswahlLeeren = () => { auswahl.value = []; }; + // Aufträge, die im aktuellen Tab als Karten sichtbar sind (für „Alle auswählen"). + function sichtbareOrders() { + switch (tab.value) { + case 'neu': return neueOrders.value; + case 'druck': return ordersByStatus('im_druck'); + case 'kuvertierung': return ordersByStatus('kuvertieren'); + case 'versandfertig': return ordersByStatus('versandfertig'); + case 'versendet': return versendetAktiv(); + case 'archiv': return archivListe(); + default: return []; + } + } + const alleSichtbarGewaehlt = () => { + const ids = sichtbareOrders().map((o) => String(o.id)); + return ids.length > 0 && ids.every((id) => auswahl.value.includes(id)); + }; + function alleAuswaehlen() { + const ids = sichtbareOrders().map((o) => String(o.id)); + auswahl.value = alleSichtbarGewaehlt() + ? auswahl.value.filter((id) => !ids.includes(id)) + : [...new Set([...auswahl.value, ...ids])]; + } + // Status für alle ausgewählten Aufträge auf einmal setzen. + async function bulkStatus(neuStatus) { + const sel = orders.value.filter((o) => istGewaehlt(o.id)); + if (!sel.length || !neuStatus) return; + let ok = 0; + for (const o of sel) { + const patch = { production_status: neuStatus }; + if (neuStatus === 'versendet' && !o.versandt_am) patch.versandt_am = new Date().toISOString(); + try { + await api.patch(`/items/orders/${o.id}`, patch); + o.production_status = neuStatus; + if (patch.versandt_am) o.versandt_am = patch.versandt_am; + ok++; + } catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Statuswechsel fehlgeschlagen.'; } + } + flash(`${ok} Auftrag/Aufträge → ${STATUS_LABEL[neuStatus] || neuStatus}`); + auswahlLeeren(); + } + // Standard-Template je Format vorbelegen (Operator kann im Popup ändern). + function stdTpl(formatKey, typ) { + const key = typ === 'umschlag' ? (ENVELOPE_TPL[formatKey] || 'kuvert_a_v1') : (LETTER_TPL[formatKey] || 'a4_single'); + const t = templates.value.find((x) => x.key === key && x.type === typ) || templates.value.find((x) => x.type === typ); + return t?.id ?? null; + } + function openBulk() { + const sel = orders.value.filter((o) => istGewaehlt(o.id)); + if (!sel.length) return; + const map = {}; + const add = (key, label, typ) => { + if (!map[key]) map[key] = { key, label, type: typ, template: stdTpl(key, typ), machine: aktiveMaschinen.value[0]?.id ?? null }; + }; + for (const o of sel) { + add(o.format?.key || 'a4', o.format?.name || o.format?.key || 'A4', 'brief'); + if (o.needs_envelope && o.envelope_labeling && o.envelope_labeling !== 'keine') { + const ek = o.envelope_format || 'c6'; + add(ek, ENV_LABEL[ek] || ek, 'umschlag'); + } + } + bulk.gruppen = Object.values(map); + bulk.mode = 'queue'; bulk.msg = ''; bulk.err = ''; bulk.open = true; + } + async function submitBulk() { + const byFormat = {}; + for (const g of bulk.gruppen) { + if (!g.template || !g.machine) { bulk.err = `Für „${g.label}" bitte Template und Maschine wählen.`; return; } + byFormat[g.key] = { template: g.template, machine: g.machine, label: g.label }; + } + bulk.err = ''; bulk.msg = 'Wird eingereiht …'; + try { + const res = await api.post('/skrift-orders/enqueue-bulk', { orders: auswahl.value, mode: bulk.mode, byFormat }); + const n = res.data?.jobs?.length || 0; + const skip = res.data?.uebersprungen || []; + flash(`${n} Job(s) eingereiht${skip.length ? ` · ${skip.length} übersprungen` : ''}.`); + bulk.open = false; auswahlLeeren(); await laden(); + } catch (e) { bulk.err = e?.response?.data?.error || e?.message || 'Sammel-Einreihen fehlgeschlagen.'; bulk.msg = ''; } + } + + // ── Format-Warteschlange: welches Format liegt an der Maschine ein? ─────── + async function setMachineFormat(machine, key) { + try { + await api.patch(`/items/machines/${machine.id}`, { current_format: key || null }); + machine.current_format = key || null; + flash(key ? `„${key}" als eingelegtes Format an ${machine.name} gesetzt.` : `Format an ${machine.name} geleert – Maschine wartet.`); + } catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Format konnte nicht gesetzt werden.'; } + } + // ── Druck-Popup ── const tplByKey = (key, typ) => templates.value.find((t) => t.key === key && t.type === typ) || templates.value.find((t) => t.type === typ) || null; const tplToFelder = (t) => ({ tplId: t?.id ?? null, width: t?.width ?? 0, height: t?.height ?? 0, xpos: t?.xpos ?? 0, ypos: t?.ypos ?? 0, scale: t?.scale ?? 1, rotation: t?.rotation ?? 0 }); @@ -420,9 +534,13 @@ const Board = defineComponent({ function orderCard(order, opts) { const o = order; const zeigeBadges = opts?.badges; const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;' }, [ - h('div', {}, [ - h('div', { style: 'font-weight:700;font-size:15px;' }, `#${o.order_number}`), - subdued(`${kundeName(o)}${o.customer?.email ? ' · ' + o.customer.email : ''}${o.customer?.phone ? ' · ' + o.customer.phone : ''}`, 'margin-top:2px;'), + h('div', { style: 'display:flex;gap:10px;align-items:flex-start;' }, [ + h('input', { type: 'checkbox', checked: istGewaehlt(o.id), onChange: () => toggleAuswahl(o.id), + title: 'Für Sammel-Druck auswählen', style: 'width:16px;height:16px;margin-top:3px;cursor:pointer;' }), + h('div', {}, [ + h('div', { style: 'font-weight:700;font-size:15px;' }, `#${o.order_number}`), + subdued(`${kundeName(o)}${o.customer?.email ? ' · ' + o.customer.email : ''}${o.customer?.phone ? ' · ' + o.customer.phone : ''}`, 'margin-top:2px;'), + ]), ]), statusSelect(o), ]); @@ -471,16 +589,45 @@ const Board = defineComponent({ if (tab.value === 'warteschlange') { const gruppen = queuedByMachine.value; if (!gruppen.length) return subdued('Warteschlange leer.'); - return gruppen.map((g) => h('div', { style: 'margin-bottom:18px;' }, [ - h('div', { style: 'font-weight:700;margin-bottom:8px;' }, `🖨 ${g.machine.name}${g.machine.status ? ' · ' + g.machine.status : ''}`), - ...g.jobs.map((j) => h('div', { key: 'j' + j.id, style: CARD + 'margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;' }, [ - h('div', {}, [ - h('div', { style: 'font-weight:600;' }, `#${j.order?.order_number || j.output_ref || j.id} · ${j.type}`), - subdued(`${j.numbers ? 'Nr. ' + j.numbers : 'alle'}${j.priority ? ' · Direkt' : ''}`), + return gruppen.map((g) => { + const m = g.machine; + const aktiv = (m.current_format || '').trim(); + // Jobs je Maschine nach Format bündeln (jede Format-Queue eigenständig). + const fmt = {}; + for (const j of g.jobs) { + const key = j.format || ''; + (fmt[key] || (fmt[key] = { key, label: j.format_label || j.format || 'Ohne Format', jobs: [] })).jobs.push(j); + } + const formatBlock = (fg) => { + const laeuft = !!aktiv && fg.key === aktiv; + const kopf = h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:8px;' }, [ + h('div', { style: 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;' }, [ + h('span', { style: `font-weight:700;font-size:14px;color:${ACCENT};` }, fg.label), + chip(`${fg.jobs.length} Job(s)`), + laeuft ? chip('▶ liegt ein – wird gedruckt', 'var(--success-25,rgba(46,125,50,.15))') + : (aktiv ? chip('⏸ wartet (anderes Format liegt ein)') : null), + ]), + laeuft + ? btn('⏹ Stapel stoppen', () => setMachineFormat(m, null), 'danger') + : btn('▶ Dieses Format liegt ein → starten', () => setMachineFormat(m, fg.key), 'primary'), + ]); + const cards = fg.jobs.map((j) => h('div', { key: 'j' + j.id, style: CARD + 'margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;' }, [ + h('div', {}, [ + h('div', { style: 'font-weight:600;' }, `#${j.order?.order_number || j.output_ref || j.id} · ${j.type === 'umschlag' ? '✉ Kuvert' : '📄 Brief'}`), + subdued(`${j.numbers ? 'Nr. ' + j.numbers : 'alle'}${j.priority ? ' · Direkt' : ''}`), + ]), + btn('Entfernen', () => cancelJob(j), 'danger'), + ])); + return h('div', { style: SECTION }, [kopf, ...cards]); + }; + return h('div', { style: 'margin-bottom:22px;' }, [ + h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:8px;' }, [ + h('div', { style: 'font-weight:700;' }, `🖨 ${m.name}${m.status ? ' · ' + m.status : ''}`), + aktiv ? subdued(`Eingelegtes Format: ${aktiv}`) : subdued('Kein Format eingelegt – bitte einen Stapel starten.'), ]), - btn('Entfernen', () => cancelJob(j), 'danger'), - ])), - ])); + ...Object.values(fmt).map(formatBlock), + ]); + }); } if (tab.value === 'druck') { const list = ordersByStatus('im_druck'); @@ -573,6 +720,44 @@ const Board = defineComponent({ ]); return overlay([modalKopf(`Drucken · #${druck.order.order_number}`, () => { druck.open = false; }), body, foot], () => { druck.open = false; }, '640px'); } + function bulkModal() { + if (!bulk.open) return null; + const gruppeRow = (g) => { + const tplOpts = templates.value.filter((t) => t.type === g.type); + return h('div', { style: SECTION }, [ + h('div', { style: 'font-weight:700;margin-bottom:10px;' }, `${g.type === 'umschlag' ? '✉' : '📄'} ${g.label}`), + h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px;' }, [ + h('div', {}, [ + h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Template'), + h('select', { style: S_SELECT + 'width:100%;', value: g.template, onChange: (e) => { g.template = e.target.value; } }, + tplOpts.length ? tplOpts.map((t) => h('option', { value: t.id, selected: String(g.template) === String(t.id) }, t.name)) : [h('option', { value: '' }, 'kein Template')]), + ]), + h('div', {}, [ + h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Maschine'), + h('select', { style: S_SELECT + 'width:100%;', value: g.machine, onChange: (e) => { g.machine = e.target.value; } }, + aktiveMaschinen.value.length ? aktiveMaschinen.value.map((mm) => h('option', { value: mm.id, selected: String(g.machine) === String(mm.id) }, mm.name)) : [h('option', { value: '' }, 'keine Maschine')]), + ]), + ]), + ]); + }; + const body = h('div', { style: 'padding:18px 20px;' }, [ + subdued(`${auswahl.value.length} Auftrag/Aufträge · pro Format einmal Template und Maschine wählen.`, 'margin-bottom:14px;'), + ...bulk.gruppen.map(gruppeRow), + h('div', { style: 'margin:6px 0 4px;' }, [ + segToggle(bulk.mode, [ + { key: 'direct', label: '⚡ Direktdruck' }, + { key: 'queue', label: '⏳ Warteschlange' }, + ], (k) => { bulk.mode = k; }), + ]), + bulk.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;margin-top:8px;' }, bulk.err) : null, + bulk.msg ? subdued(bulk.msg, 'margin-top:8px;') : null, + ]); + const foot = h('div', { style: 'display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border-subdued);' }, [ + btn('Abbrechen', () => { bulk.open = false; }), + btn('In Produktion geben', submitBulk, 'primary'), + ]); + return overlay([modalKopf('Sammel-Druck', () => { bulk.open = false; }), body, foot], () => { bulk.open = false; }, '640px'); + } function musterModal() { if (!manuell.open) return null; const briefTpls = templates.value.filter((t) => t.type === 'brief'); @@ -677,18 +862,33 @@ const Board = defineComponent({ const kopfzeile = h('div', { style: 'display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:18px;' }, [ h('input', { type: 'search', value: suche.value, placeholder: '🔎 Auftrag, Kunde, E-Mail, Produkt …', style: S_INPUT + 'flex:1;min-width:240px;', onInput: (e) => { suche.value = e.target.value; } }), + sichtbareOrders().length ? btn(alleSichtbarGewaehlt() ? '☐ Auswahl aufheben' : '☑ Alle auswählen', alleAuswaehlen) : null, btn('↻ Aktualisieren', laden), ]); // Leicht getönte Inhaltsfläche → weiße Karten heben sich klar ab (wie im Referenz-Dashboard). + const bulkBar = auswahl.value.length ? h('div', { style: `display:flex;align-items:center;gap:12px;flex-wrap:wrap;background:${ACCENT};color:#fff;padding:10px 16px;border-radius:8px;margin-bottom:12px;` }, [ + h('span', { style: 'font-weight:600;' }, `${auswahl.value.length} Auftrag/Aufträge ausgewählt`), + h('button', { type: 'button', onClick: openBulk, style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;font-weight:600;` }, '🖨 In Druck geben'), + h('select', { + style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:7px 12px;font:inherit;font-size:13px;font-weight:600;`, + value: '', onChange: (e) => { if (e.target.value) { bulkStatus(e.target.value); e.target.value = ''; } }, + }, [ + h('option', { value: '' }, 'Status setzen …'), + ...Object.entries(STATUS_LABEL).map(([k, v]) => h('option', { value: k }, v)), + ]), + h('button', { type: 'button', onClick: auswahlLeeren, style: 'cursor:pointer;border:1px solid rgba(255,255,255,.6);background:transparent;color:#fff;border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;' }, 'Auswahl aufheben'), + ]) : null; + const inhalt = h('div', { style: 'padding:24px 28px;min-height:100%;background:var(--background-subdued,#f1f4f9);' }, [ kopfzeile, + bulkBar, meldung.value ? h('div', { style: 'background:var(--success-25,rgba(46,125,50,.15));color:var(--success,#2e7d32);padding:9px 14px;border-radius:8px;margin-bottom:12px;font-size:13px;' }, meldung.value) : null, fehler.value ? h('div', { style: 'background:var(--danger-25,rgba(198,40,40,.12));color:var(--danger,#c62828);padding:9px 14px;border-radius:8px;margin-bottom:12px;font-size:13px;' }, fehler.value) : null, loading.value ? subdued('Lädt …') : h('div', { style: 'display:flex;flex-direction:column;gap:2px;' }, tabInhalt()), ]); - const modale = [druckModal(), musterModal(), empfModal(), lightboxModal()].filter(Boolean); + const modale = [druckModal(), bulkModal(), musterModal(), empfModal(), lightboxModal()].filter(Boolean); return h(resolveComponent('private-view'), { title: 'Produktion' }, { navigation: () => navi, diff --git a/Docker/skrift-backend/src/api/controllers/directus-controller.js b/Docker/skrift-backend/src/api/controllers/directus-controller.js index ff9c26c..584829a 100644 --- a/Docker/skrift-backend/src/api/controllers/directus-controller.js +++ b/Docker/skrift-backend/src/api/controllers/directus-controller.js @@ -45,7 +45,8 @@ function placeholdersOf(entry) { /** Übersetzt einen Directus-Auftrag in die Dokumentliste für runGeneration. */ function buildDocuments(order, entries) { const font = order.font || 'tilda'; - const letterFormat = LETTER_FORMAT[order.format?.key] || 'a4'; + // Eigene Format-Schlüssel (freie Formate) unverändert durchreichen. + const letterFormat = LETTER_FORMAT[order.format?.key] || order.format?.key || 'a4'; const envFormat = ENVELOPE_FORMAT[order.envelope_format] || 'c6'; const wantsEnvelope = !!order.needs_envelope && order.envelope_labeling !== 'keine'; const envelopeType = order.envelope_labeling === 'freitext' ? 'custom' : 'recipient'; @@ -72,7 +73,7 @@ function buildDocuments(order, entries) { index: nr, type: 'envelope', envelopeType, - text: envelopeType === 'recipient' ? addressText(entry) : (order.text_template || ''), + text: envelopeType === 'recipient' ? addressText(entry) : (order.envelope_text || order.text_template || ''), format: envFormat, font, placeholders, diff --git a/Docker/skrift-backend/src/lib/page-layout.js b/Docker/skrift-backend/src/lib/page-layout.js index da2648c..328ee60 100644 --- a/Docker/skrift-backend/src/lib/page-layout.js +++ b/Docker/skrift-backend/src/lib/page-layout.js @@ -57,6 +57,7 @@ function applyRenderFormats(rows) { PAGE_FORMATS = pf; FORMAT_LAYOUTS = fl; LINE_LIMITS = ll; + signaledMisses.clear(); } // mm in Pixel umrechnen @@ -64,10 +65,27 @@ function mmToPx(mm) { return mm * PX_PER_MM; } +// ── Refresh bei unbekanntem Format ────────────────────────────────────────── +// Wird ein Format angefragt, das (noch) nicht im Cache liegt, meldet page-layout +// das einmalig an einen Handler (den der Server auf refreshRenderFormats setzt). +// So werden neu in Directus angelegte Formate sofort nachgeladen, ohne die +// stündliche Aktualisierung abzuwarten. Für DIESEN Aufruf greift noch der +// Default – der nächste Aufruf hat das Format. +let missHandler = null; +const signaledMisses = new Set(); +function setFormatMissHandler(fn) { missHandler = fn; } +function signalMiss(key) { + const k = String(key || '').toLowerCase(); + if (!missHandler || !k || signaledMisses.has(k)) return; + signaledMisses.add(k); + try { missHandler(k); } catch { /* egal */ } +} + // Layout für Format holen function getLayoutForFormat(format) { const normalizedFormat = String(format).toLowerCase(); const layoutConfig = FORMAT_LAYOUTS[normalizedFormat]; + if (!layoutConfig) signalMiss(normalizedFormat); if (!layoutConfig) { // Fallback auf a4 @@ -89,6 +107,7 @@ function getLayoutForFormat(format) { // Seiten-Dimensionen holen function getPageDimensions(format) { const normalizedFormat = String(format).toLowerCase(); + if (!PAGE_FORMATS[normalizedFormat]) signalMiss(normalizedFormat); const dimensions = PAGE_FORMATS[normalizedFormat] || PAGE_FORMATS.a4; return { @@ -110,6 +129,7 @@ function escapeXml(str) { // Zeilenlimit für Format holen function getLineLimit(format) { const normalizedFormat = String(format).toLowerCase(); + if (!LINE_LIMITS[normalizedFormat]) signalMiss(normalizedFormat); return LINE_LIMITS[normalizedFormat] || LINE_LIMITS.a4; } @@ -122,4 +142,5 @@ module.exports = { getLineLimit, escapeXml, applyRenderFormats, + setFormatMissHandler, }; diff --git a/Docker/skrift-backend/src/server.js b/Docker/skrift-backend/src/server.js index 8ca4bd9..0df1b3f 100644 --- a/Docker/skrift-backend/src/server.js +++ b/Docker/skrift-backend/src/server.js @@ -9,12 +9,14 @@ const healthRoutes = require('./api/routes/health-routes'); const errorHandler = require('./api/middleware/error-handler'); const requestLogger = require('./api/middleware/request-logger'); -const { applyRenderFormats } = require('./lib/page-layout'); +const { applyRenderFormats, setFormatMissHandler } = require('./lib/page-layout'); const { fetchRenderFormats } = require('./services/directus-service'); // Zentrale Format-Definitionen aus Directus in den Cache holen. Läuft beim Start // und stündlich; Fehler sind unkritisch (die Defaults im page-layout greifen). +let lastFormatRefresh = 0; async function refreshRenderFormats() { + lastFormatRefresh = Date.now(); try { const rows = await fetchRenderFormats(); applyRenderFormats(rows); @@ -24,6 +26,14 @@ async function refreshRenderFormats() { } } +// Unbekanntes Format angefragt → sofort nachladen (max. alle 30 s, damit ein +// dauerhaft unbekanntes Format kein Dauerfeuer auslöst). +setFormatMissHandler((key) => { + if (Date.now() - lastFormatRefresh < 30 * 1000) return; + console.log(`[Formate] Unbekanntes Format "${key}" angefragt – lade Formate nach.`); + refreshRenderFormats(); +}); + const app = express(); // Middleware diff --git a/Docker/skrift-backend/src/services/directus-service.js b/Docker/skrift-backend/src/services/directus-service.js index d1bf8d9..1d9af99 100644 --- a/Docker/skrift-backend/src/services/directus-service.js +++ b/Docker/skrift-backend/src/services/directus-service.js @@ -41,7 +41,7 @@ async function directusFetch(path, options = {}) { async function fetchOrder(orderId) { const fields = [ 'id', 'order_number', 'customer_email', 'font', 'status', - 'needs_envelope', 'envelope_labeling', 'envelope_format', + 'needs_envelope', 'envelope_labeling', 'envelope_text', 'envelope_format', 'text_template', 'motif_mode', 'own_cards', 'entries_count', 'preview_session', 'source_file', 'product.key', 'product.type', 'product.input_mode', 'format.key', ].join(','); diff --git a/Produktion/skrift-agent/agent.py b/Produktion/skrift-agent/agent.py index ecdd88f..d0e911e 100644 --- a/Produktion/skrift-agent/agent.py +++ b/Produktion/skrift-agent/agent.py @@ -168,29 +168,83 @@ def main(): print(f"[Agent] gestartet für Maschine {machine_id} gegen {cfg['directus_url']}") KIND = {"brief": "schriftstueck", "umschlag": "umschlag", "signatur": "signatur"} + # Sicherheitsfenster: Sehen wir nach dem Senden binnen dieser Zeit kein + # „Printing" (Job war sehr kurz), gilt er als fertig – verhindert Deadlock. + start_grace = max(60, poll * 6) + + # Kontext des aktuell an die Maschine gesendeten Jobs (oder None). Der + # nächste Job wird ERST gesendet, wenn die Maschine wieder „Idle" ist – + # sonst würde ein neuer write_svg (clear=1) den laufenden Druck verwerfen. + laufend = None + + # Heartbeat gedrosselt: nur schreiben, wenn sich der Status ändert oder + # spätestens alle 60 s – sonst flutet der PATCH bei jedem Poll das Log. + beat_state = {"status": None, "ts": 0.0} + + def beat(status): + now = time.monotonic() + if status != beat_state["status"] or now - beat_state["ts"] >= 60: + _heartbeat(dx, machine_id, status) + beat_state["status"] = status + beat_state["ts"] = now while True: try: maschine = dx.get(f"/items/machines/{machine_id}", - fields="base_url,mid,username,password,active") + fields="base_url,mid,username,password,active,current_format") if not maschine or not maschine.get("active", True): - _heartbeat(dx, machine_id, "offline") + beat("offline") time.sleep(poll) continue plotter = Plotter(maschine) - mstatus = plotter.status() - # Bei Fehler (z. B. Papierende) automatisch fortsetzen. - if mstatus and str(mstatus.get("status", "")).lower() in ("error", "fehler"): - plotter.recover() - _heartbeat(dx, machine_id, "bereit") + st = plotter.status() or {} + s = str(st.get("status", "")).strip().lower() - job = _naechster_job(dx, machine_id) - if not job: + # Fehler (z. B. Papierende): fortsetzen, aber nichts Neues senden. + if s in ("error", "fehler"): + plotter.recover() + beat("fehler") time.sleep(poll) continue - _druck(dx, plotter, job, KIND) + # Maschine druckt noch → warten (auf keinen Fall einen neuen Job senden). + if s == "printing": + if laufend is not None: + laufend["gestartet"] = True + beat("druckt") + time.sleep(poll) + continue + + # Nur bei sicherem „Idle" handeln. Unbekannt/leer → abwarten. + if s != "idle": + beat("bereit" if s else "offline") + time.sleep(poll) + continue + + beat("bereit") + + # Ein zuvor gesendeter Job ist fertig gedruckt (Idle nach Printing) → + # abschließen. Das start_grace fängt sehr kurze Jobs ab, bei denen wir + # das „Printing" nie zu Gesicht bekommen. + if laufend is not None: + if laufend.get("gestartet") or (time.monotonic() - laufend["ts"] > start_grace): + _abschluss(dx, laufend) + laufend = None + else: + time.sleep(poll) + continue + + # Nächsten Job nur senden, wenn nichts (mehr) läuft. + if laufend is None: + current_format = (maschine.get("current_format") or "").strip() + # Format-Warteschlange: nur passendes Format, kein Auto-Anschluss. + if current_format: + job = _naechster_job(dx, machine_id, current_format) + if job: + laufend = _sende(dx, plotter, job, KIND) + + time.sleep(poll) except requests.HTTPError as e: print(f"[Agent] HTTP-Fehler: {e}") @@ -207,12 +261,14 @@ def _heartbeat(dx, machine_id, status): pass -def _naechster_job(dx, machine_id): +def _naechster_job(dx, machine_id, current_format): jobs = dx.get( "/items/jobs", **{ "filter[machine][_eq]": machine_id, "filter[status][_eq]": "queued", + # Nur Jobs des aktuell eingelegten Formats. + "filter[format][_eq]": current_format, "sort": "-priority,date_created", "limit": 1, "fields": "id,type,output_ref,params,numbers," @@ -224,7 +280,13 @@ def _naechster_job(dx, machine_id): return jobs[0] if jobs else None -def _druck(dx, plotter, job, KIND): +def _sende(dx, plotter, job, KIND): + """Sendet einen Job an die Maschine und markiert ihn als „printing". + + Gibt einen Kontext zum späteren Abschluss zurück (oder None bei Fehler). + Es wird NICHT auf das Druckende gewartet – das übernimmt die Hauptschleife + (erst wenn die Maschine wieder „Idle" ist, wird der nächste Job gesendet). + """ job_id = job["id"] order = job.get("order") or {} order_id = order.get("id") @@ -239,7 +301,7 @@ def _druck(dx, plotter, job, KIND): tpl[k] = params[k] nummern = parse_nummern(job.get("numbers")) typ = job.get("type") or "brief" - print(f"[Agent] Job {job_id}: {typ} für {order_nr}") + print(f"[Agent] Job {job_id}: {typ} für {order_nr} → an Maschine senden") dx.patch(f"/items/jobs/{job_id}", {"status": "printing", "error": None}) # Produktionsstatus fürs Kanban: sobald gedruckt wird → „im_druck". @@ -271,27 +333,44 @@ def _druck(dx, plotter, job, KIND): plotter.login() plotter.write(dateien, tpl) - - dx.patch(f"/items/jobs/{job_id}", {"status": "printed"}) - # Indikator am Auftrag setzen (Schriftstück bzw. Umschlag gedruckt) und - # bei Vollständigkeit automatisch in die Kuvertierung schieben. - if order_id: - feld = "kuvert_gedruckt" if typ == "umschlag" else "brief_gedruckt" - try: - dx.patch(f"/items/orders/{order_id}", {feld: True}) - stand = dx.get(f"/items/orders/{order_id}", - fields="brief_gedruckt,kuvert_gedruckt,needs_envelope,production_status") or {} - braucht_kuvert = bool(stand.get("needs_envelope")) - fertig = stand.get("brief_gedruckt") and (stand.get("kuvert_gedruckt") or not braucht_kuvert) - if fertig and stand.get("production_status") == "im_druck": - dx.patch(f"/items/orders/{order_id}", {"production_status": "kuvertieren"}) - print(f"[Agent] Auftrag {order_nr} vollständig gedruckt → Kuvertierung.") - except Exception: - pass - print(f"[Agent] Job {job_id} gedruckt ({len(dateien)} Dateien).") + print(f"[Agent] Job {job_id} an Maschine übergeben ({len(dateien)} Dateien) – warte auf Fertigstellung.") + return {"job_id": job_id, "order_id": order_id, "order_nr": order_nr, + "typ": typ, "gestartet": False, "ts": time.monotonic()} except Exception as e: dx.patch(f"/items/jobs/{job_id}", {"status": "failed", "error": str(e)[:1000]}) print(f"[Agent] Job {job_id} fehlgeschlagen: {e}") + return None + + +def _abschluss(dx, ctx): + """Schließt einen fertig gedruckten Job ab: Status, Indikatoren, Kuvertierung. + + Wird erst aufgerufen, wenn die Maschine nach dem Druck wieder „Idle" meldet. + """ + job_id = ctx["job_id"] + order_id = ctx.get("order_id") + order_nr = ctx.get("order_nr") + typ = ctx.get("typ") + try: + dx.patch(f"/items/jobs/{job_id}", {"status": "printed"}) + except Exception as e: + print(f"[Agent] Konnte Job {job_id} nicht auf 'printed' setzen: {e}") + # Indikator am Auftrag setzen (Schriftstück bzw. Umschlag gedruckt) und + # bei Vollständigkeit automatisch in die Kuvertierung schieben. + if order_id: + feld = "kuvert_gedruckt" if typ == "umschlag" else "brief_gedruckt" + try: + dx.patch(f"/items/orders/{order_id}", {feld: True}) + stand = dx.get(f"/items/orders/{order_id}", + fields="brief_gedruckt,kuvert_gedruckt,needs_envelope,production_status") or {} + braucht_kuvert = bool(stand.get("needs_envelope")) + fertig = stand.get("brief_gedruckt") and (stand.get("kuvert_gedruckt") or not braucht_kuvert) + if fertig and stand.get("production_status") == "im_druck": + dx.patch(f"/items/orders/{order_id}", {"production_status": "kuvertieren"}) + print(f"[Agent] Auftrag {order_nr} vollständig gedruckt → Kuvertierung.") + except Exception: + pass + print(f"[Agent] Job {job_id} fertig gedruckt.") if __name__ == "__main__": diff --git a/Webseite/skrift-app/src/lib/excel.ts b/Webseite/skrift-app/src/lib/excel.ts index 50307c3..dd5fd45 100644 --- a/Webseite/skrift-app/src/lib/excel.ts +++ b/Webseite/skrift-app/src/lib/excel.ts @@ -47,14 +47,19 @@ export const VORLAGEN_SPALTEN = [ /** Spalten der Freitext-Variante: fünf frei beschreibbare Zeilen. */ export const FREITEXT_SPALTEN = ['Zeile 1', 'Zeile 2', 'Zeile 3', 'Zeile 4', 'Zeile 5']; -/** Erzeugt die Vorlagendatei und stößt den Download an. */ -export async function ladeVorlageHerunter(zusatzSpalten: string[] = [], freitext = false) { +/** + * Erzeugt die Vorlagendatei und stößt den Download an. + * `nurZusatz` = nur die Platzhalter-Spalten (ohne Adressspalten) – für den + * Fall, dass nur Platzhalterwerte je Dokument gebraucht werden (Sammelversand + * ohne Kuvert), nicht aber Empfängeradressen. + */ +export async function ladeVorlageHerunter(zusatzSpalten: string[] = [], freitext = false, nurZusatz = false) { const XLSX = await import('xlsx'); - const basis = freitext ? FREITEXT_SPALTEN : VORLAGEN_SPALTEN; + const basis = nurZusatz ? [] : (freitext ? FREITEXT_SPALTEN : VORLAGEN_SPALTEN); const kopf = [...basis, ...zusatzSpalten]; - const beispielBasis = freitext + const beispielBasis = nurZusatz ? [] : (freitext ? ['Erika Mustermann', 'Hauptstraße 1', '10115 Berlin', '', ''] - : ['Frau', 'Erika', 'Mustermann', 'Hauptstraße', '1', '10115', 'Berlin', 'DE']; + : ['Frau', 'Erika', 'Mustermann', 'Hauptstraße', '1', '10115', 'Berlin', 'DE']); const beispiel = [...beispielBasis, ...zusatzSpalten.map(() => 'Beispielwert')]; const blatt = XLSX.utils.aoa_to_sheet([kopf, beispiel]); diff --git a/Webseite/skrift-app/src/routes/Configurator.tsx b/Webseite/skrift-app/src/routes/Configurator.tsx index 5f207b8..76d50dc 100644 --- a/Webseite/skrift-app/src/routes/Configurator.tsx +++ b/Webseite/skrift-app/src/routes/Configurator.tsx @@ -354,7 +354,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri const verbotenRe = useMemo(() => { const wl = settings?.char_whitelist; if (!wl) return null; - try { return new RegExp(`[^${wl}]`, 'g'); } catch { return null; } + // Zeilenumbrüche sind im mehrzeiligen Text erlaubt; einzeilige Felder + // entfernen \n/\r ohnehin separat vor saeubere(). + try { return new RegExp(`[^${wl}\\n\\r]`, 'g'); } catch { return null; } }, [settings?.char_whitelist]); /** Entfernt verbotene Zeichen und meldet sie; gibt den bereinigten Wert zurück. */ const saeubere = (v: string) => { @@ -717,6 +719,15 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri const genutztePlatzhalter = useMemo(() => textPlatzhalter(textTemplate), [textTemplate]); /** Davon die eigenen (nicht aus der Adresse ableitbaren) – nur diese sind editierbar. */ const eigeneImText = genutztePlatzhalter.filter((p) => !standardSet.has(p.toLowerCase())); + /** Platzhalter im individuellen Umschlagtext – analog zur Text-Tabelle. */ + const genutztePlatzhalterUmschlag = useMemo(() => textPlatzhalter(umschlagText), [umschlagText]); + /** + * Werden Platzhalterwerte NICHT bereits in einem Empfänger-Schritt erfasst + * (kein Adress-Schritt, kein Umschlag-Freitext)? Dann muss der Text-Schritt + * selbst Tabelle UND Excel-Upload für die Platzhalterwerte anbieten – auch + * beim Sammelversand ohne Kuvert. + */ + const platzhalterNurImText = !brauchtAdressen; // PayPal-Buttons erscheinen erst nach der Bestellung – dann steht der @@ -1040,6 +1051,16 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri Den aktuellen Stand sehen Sie jederzeit unter „Aufträge" – melden Sie sich dafür mit Ihrer E-Mail-Adresse an.

+ + {/* Zurück zur Hauptseite – erst wenn die Bestellung abgeschlossen ist + (bei offener PayPal-Zahlung nicht, damit niemand vorher abspringt). */} + {!offenePayPalZahlung && ( +
+ + Zurück zu skrift.de + +
+ )} ); @@ -1296,6 +1317,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri {/* ── Empfängerdaten ──────────────────────────────────── */} {/* ── Individueller Umschlagtext (ersetzt die Adressen) ── */} {aktuell === 'Individueller Text' && ( + <>

Individueller Text

@@ -1304,16 +1326,62 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
- setUmschlagText(saeubere(e.target.value.replace(/[\r\n]/g, '')))} /> + onChange={(e) => setUmschlagText(saeubere(e.target.value))} />

- {umschlagText.length}/{MAX_UMSCHLAG_TEXT} Zeichen · keine Zeilenumbrüche + {umschlagText.length}/{MAX_UMSCHLAG_TEXT} Zeichen

+ + {/* Platzhalter-Tabelle: erscheint, sobald der Umschlagtext Platzhalter + nutzt – je Empfänger ein Wert (analog zur Text-Tabelle). */} + {genutztePlatzhalterUmschlag.length > 0 && ( +
+
+

Platzhalter-Werte

+

Füllen Sie die im Umschlagtext genutzten Platzhalter je Empfänger aus.

+
+
+
+ + + + + {genutztePlatzhalterUmschlag.map((p) => )} + + + + {entries.map((e, i) => ( + + + {genutztePlatzhalterUmschlag.map((p) => { + const standard = standardSet.has(p.toLowerCase()); + return ( + + ); + })} + + ))} + +
#{p}
{i + 1} + setPlatzhalter(i, p, ev.target.value)} + /> +
+
+
+
+ )} + )} {aktuell === 'Empfänger' && ( @@ -1547,15 +1615,48 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri

+ {platzhalterNurImText && eigeneImText.length > 0 && ( +
+
+ )} {eingabeArt === 'excel' ? ( -

- Die Werte kommen aus Ihrer Excel-Datei. - {eigeneImText.length > 0 && ( - <> Ergänzen Sie eigene Platzhalter ({eigeneImText.map((p) => `[[${p}]]`).join(', ')}) - dort als zusätzliche Spalten – laden Sie die Vorlage im Schritt „Empfänger" neu, - falls nötig. - )} -

+ platzhalterNurImText ? ( +
+ + + {excelDatei && !excelFehler && ( +

+ {excelDatei.name} – {entries.length} Zeile(n) gelesen. +

+ )} + {excelFehler &&

{excelFehler}

} +
+ ) : ( +

+ Die Werte kommen aus Ihrer Excel-Datei. + {eigeneImText.length > 0 && ( + <> Ergänzen Sie eigene Platzhalter ({eigeneImText.map((p) => `[[${p}]]`).join(', ')}) + dort als zusätzliche Spalten – laden Sie die Vorlage im Schritt „Empfänger" neu, + falls nötig. + )} +

+ ) ) : (
diff --git a/Webseite/skrift-app/src/styles/global.css b/Webseite/skrift-app/src/styles/global.css index 0f728d1..1e6786f 100644 --- a/Webseite/skrift-app/src/styles/global.css +++ b/Webseite/skrift-app/src/styles/global.css @@ -421,6 +421,9 @@ button { font-family: inherit; } border-radius: var(--sk-radius-lg); padding: var(--sk-space-4); background: var(--sk-white); + /* Über den (sticky) Tabellenzellen, damit die Platzhalter-Tabelle nicht in + die Vorschau-Spalte hineinmalt. */ + z-index: 20; } .sk-preview-head { display: flex; @@ -625,10 +628,12 @@ button { font-family: inherit; } padding: 10px 12px; white-space: nowrap; background: var(--sk-card); - /* bleibt beim Scrollen stehen – wie im bestehenden Konfigurator */ + /* bleibt beim Scrollen stehen – wie im bestehenden Konfigurator. + Niedriger z-index als Vorschau/Preisleiste, damit die Tabelle nicht + über benachbarte Panels malt. */ position: sticky; top: 0; - z-index: 10; + z-index: 3; border-bottom: 2px solid rgba(15, 23, 42, 0.1); } .sk-table td { @@ -650,7 +655,7 @@ button { font-family: inherit; } /* bleibt beim horizontalen Scrollen sichtbar */ position: sticky; left: 0; - z-index: 5; + z-index: 2; } .sk-cell { width: 100%; diff --git a/Webseite/skrift-preisrechner.zip b/Webseite/skrift-preisrechner.zip index 9cc468911e3281ebb2e2ca70469c1fcf288d42aa..7acbca50bd84faa8ce010a7d16b11954b842e73f 100644 GIT binary patch literal 8802 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 Zt^aBY_Am1c0092=vOxg=Bou#E{{dXLDJ}p2 delta 8310 zcmYkBWl)_glfauWL=!vnNq4j;SIKkAM&JZ!}mxCSZOfHh%nUQ~J*}-~Md#pC$?n3QU|uf}sT9 zaQoRN?4RDhxmw%6VMFSj{|dt}40$3U&6;-;H8NIWlV}2!RStf*Eeewj4a;CJb6P^q z;3tYmaDKN~TmtVlHQmYNt+aeZ;yEoGb$BP|_N4!>#tCvk*+bbXPq)Ru4^Ip`RD1J5 z>QAn$?ZHI#7~ZqqAO=>vu^Xan01*xL?{~OXc{xWbRt-<1MQL}mt5w0B1Z2kye7kRd zTMB|qgs^vYh+QJYb=)3n4;Z>|Ra1R}Z&N2gy3&n1*_fZ%gE@)6JE0(puLPklieWkt z3x4Iu4!oioMxYHGh+@{I$B`DK%D(GX2UFk7!}BN!p&(|WuQek>q%g5d0a3DjG(T22 zmpr%JdKLQk%{*!SzTlE;{!xoI8Irf%=b0nNowZ*^Xsm+v_;h)wiz+&3uA&$SUrJFF ziqU%BZm>81fE3#%3qf%KlI#_u!6otNB$Mi75E>|;6grw=SVdF1L#FVAzt8S`{;RCF zN4WQC&ck45MeJS=pTR2)&=a3TnQnBW;siv&27}>Q9;lX?lEMjaCt{HmB#O-^3G=wI zLuf2S!p$%unrPxqP#Q@t*MiK;T3TlZYHwWjJb?$M(nEs{c+y_Y(}#kmJD>bAdK9JB z4fowMBS8GVNo+tpvwFuTZ2D@V#dr0C%vmvlWYfUo>7h!{PM$s>WCUlz&af7)M@L$Re@$wCq;{_CezklfhI3Pp=@%BE(6HPTPSZ>Gn>0J_h}Qf=uWos;qu{&56}? zz6ms8fPSG?(Y^*q%+EnOXS&dQR(T0+i5yyDIV?6Z)M(mJB{az<`$k);FVo0@8@p<_ zo0G;?uO`^xKwf83Lt<|dk2sc{f{}_4dww;9jP8Wf`9v%ffXyMw-$RGFJVHa1Jc>kX z;(fhZO;;W+)sC$@z(meZ3gyPUyHfcJ#;~_5k+Tq-*_#3+2T_;6ea6o}L43g@QxQEH zTWoIf^DYFN0uWG&?w@6QTfMk`q*LudvYH`X7qjYcPgF|wWMLjxTvvDQ3VC^b12EBN zao*^;q%Z0pE+7I!7C80sMXI!x)=Qu@4px<725DEWuNL~7!Q+!`BTmZvm*68Nr8Ke6 zYBM*JcK3kJcwec%RbI_<5FwINbUI1^dxybZKF*;&HZM1n>Ha!luq)G*4s$vOf7%{q z56K#C&$P&3Q%sQqOnW-aAj6m=m0@Bfo@H>gVzHvvbYB|t64K&t=LU5=V-o$dN-sm& z+b1kU;?iuk1iO{X5rI#c#5?^Vry{wYV%%8Gzy!$M6D^ffZw<}VnhnfMRa$tHQ)H=B zD5b5E*O>KCOPPfe*G=8)Kzk@yAC_V$uRf3?saO_nAzZ zD)%l>t%36D2sG@>#cA`%TsqPd`T2)w%y?)i2oI*t-D;w`D8t=D&yq<`nmhk;U7&`T zr~=m%tj%KGdlRCK6x^0_OSDAham4{y?MbdQ6oiRkYS6=GuI}oR*Icp5LUQxAfwb5R zypRedJU4OS^REuPpSJPZRXfhPxyM^MaVA^~gGlPTxsl09|ovP_>Yg7duuMcT)M1meFG z%q@=%tIwK*q(U19bp}qW(;o+coaV$XB2#}dT7_HR2B}vE(+lCFE*loUPJZOHgL7f@ z{i_TxkMhv=ch`O0V>NQ*eKWuPi8$)xAJ#ZA0n->V0?{;K-|@lcIKo9qN3-L2csckfDM5c?438Bg;WkVMTaw|6S50PuSE`vOUp+ zv}bHodvuyx7MkXTUjyibFz7dhiaavPO6CBZ5)ur|3IPnv|GE&uzaEuDi>w0tYf}Fj z(Z6|0f5T;CG^6KHt%+a?rpfzVH)j$Enk)~V0Ht9UH6%}J;uD}#XHtv33-}t!U|m$X zxZKmHdGYg7k@b;Z%e9PmEb095?-mTndj{J?{cokiRHT6C-y=0%wNp?eTtQsr zLF@FHAepX5O_%r6sn!6y+8$SmXzKV0RU$)QSMnMcCN9C?T>gW~i&qtT7p!E_{mOBhOf6qgF`_j6rXV^wd$XZ?`(HZ3e63?MI%Rh~7WXA|Y4L-e{Y|JD!m4_TOjvrQTduniA&l z&Iug3`@txr$a9b`Gr`mSTeN+HJu1!d^q`pzY=S0-^ zNAib?JO;sEyM6-{hZ|c!w~zbn+v~;I6~Koh^3{sr5Rwe~v_)h{Xx8PMMfioFj0xnS z)Qu0(gxE)Uo^dV_mc6HO;24qz1uSxYR4f@^f7J*WAwOiL-1fbrPe*;MXR{2rGdOx0 zT06OU##XALInc-tjRTk5nu3d6$fVU6hWFEIMWROvnSksc(T`VRM`Qtt`}Asi=Tl)T zwbLVI6iSA9F@T^D@Mqtb16L`=^&3-qgZc0y1q>UHh1tp+K3@LXtR?#x_V;-8S8x~g zW&G)sc?B)B%gp|+U*St)Mc9#CTX96rzF?O3=hai(pL!2~=WFf$e(Mn(ML>UT9yu9? zWG!881i*>-JaLR;`W<@w`Y@n(?6y36qR__HS5-02dG3rxW`wvZOyDV^oKLfWIGUl* zmHe3Zb1qpydC@B8IAz$Iq=V(gG44B-Rm=E-Ofe&-W=$IP?!DLWnMF;bZNmcLv{(f- z>77q*FIr(u==saM^{&p0H73QDFykg-7Uan$3;4y1834(06eS=PqdzKPNerwn`8(<$ z;f2={kr%k=*a5$fZeiwH=c_r3f}Mh*Nfik3rj(j_uMUF6_|&zC4hq+D!I5(^#tj5u zs3aV3-|^JgB*sM_f3~FdDB9ZqDavWMcmaFnxMJHZkp-by9JB$^x9nV|QGCViKY1?w z0m!c}d~D1ox8wFVB^f$)qjlM&a)YY$l}_aSh_)WF0XaWR zy8^H74{ea9MF^Xr+CgJcCGenSYBPQqaK>2x zZ;WB_vQvUnh6y6_?ZESMg)yVIyNZNdrL+uEZ*+%E8H|KTT_w|+zrGvC}0z)tM9_8Cx6J`;^*zgLYP}l>3)4QC0FU|`5Lkm6j z*(gF7L|WzX3JmGI7R40PH@p2>pnkgOQ*)V<>@U7?^@X3ZbI(GMabjAshf+iRy8__|iMq#Z1|TUUNA>6NZETe!(h4zw5Vt#RdS zULpls6c2Dq0Yu@65FJ^^6z-08+2+V}i_HpX4Q0MO)14VnK8Fr#3`m82 ztGAa696`R2qmZe;Ke9*rEMYC1PN9Nz`24n-`B4OL}I*A>r zHS84v#cgP2s)gr?6Bg=I*DewZ%e}} zW=*ViZ6?ltEqUrK9!%>EPW3Z~Hq79vfe;UytmMGl^y_7Xwi}Zh&_Hw5^a8hT5DN7D zQ0O?~gzUSD#Vqtkpp?nbO;TcMrqehV!mUyxb0~(MK4fW~i1+@1@9*{?BHhDulnbb^ zm_}%-d$yb+&2~h+9AofTrn=fk@zN&Zdlt52)l$K?sj=sfM$F~*gHJqD38PXr(Gr5> z%0lesKP6@KV;)DdNh0(#WQz<(qfQfA7x87?>(y(8D(5ShfFJQ!M~k>f&$&^gm4fYs z1+zV=IEfX+d(m}DQ}`*`M+^{EURrKrifPFhHrLCPWJ#9l6vPBGacl zg4n!`knp-HP^Y&%dJIdBrHY+!#rENE3xp~wQ~<<7P)3@O(RSH z`ESVxwjCKX%!}+{o$oKbwC0>OGzB$teBnC^?=uU{ACV%y5YHfb=3k!ZzWYHtB8D#w z_s_<_N+&KrutU;R;Vd{OAjdEughLYU@*bD@N-geWpq=wTy_f`Hpxr~qVHJb#7`#db z^M@H@Yb2Yszca7incf1n1o{|n8LVA0*cr}jOIFCaCTamKf~?6N_&0=58w6;?ke&vl zj{QKk`Fe;W_xV zotH2B<0y7JjG$mJ0=oC=ZXau}NHsUowh~tbHWTFpYu!2@5D?LA8~Y;8L2ou81`wwO zuAnKT4o-R2^|piaex08MXo>G`%oXB(%`S9a_=Nr;E2G~c0GpR6S1rLSz%M_g`_3Y} z3q2IzJA*4G>Dx3$E&>{+iXkQBvvxX_YZtS`hWe>|%enJ$L6d?Zs(^p?y&_-fZ61H0LM6}3V9yc8sjoJ2+PhCJ>^ zOGM79dPCi~Cc=!4P!TAtHJ+eEcVtdV8_; z&g6ohcVfvWvr`pW?aYI`1CR=lKrSr12kTFI9X)ebIKWf zk4I>4k$qAedZcUMa=`LC6D6?rJ`e8Zvme!}?X06|ioEhr1rl3W2h56-^oPqF_-A1! z|IlKF-|386BIh@ask!IA9bb&gl6~-$E3llhr`~J(5X0&#lb%S32Egi@Dy8)UlHP@T z%YOa_dA-(;SUo-rf-JStz3}(6(myI~BvD|Sc?6<H!a z;RLwd7;Hm7jem$jES*TkVtI!h!%Pb`wIwmrzK|B?-n_muJlUgUk_{`*bHAdde!tDu z#Z6a|ukPzwP6%n&ZLy*ywy;qH#1npoSY&bxPF&=~^p_EIc|5fWsfVo)&KR_zm9^Au zN!%yrOqo~b!FIG*^m8)ul}s!;eat`MC&OvxF16yZUZPmy3Cz7DI-OBN+li21`OBKW z>{BMfwXNLD)~5{uRVzemF0q=+Pa0<=BdI?=B_}U^;W^O2&{UBofRgqBqx~&B)_6DT zQd!RV63sj@_aoO=V=`@i#D3(3bT7!rArSf^GXwfn0E_e?CZY_v94ld z1*JQW@SWE^?-}iHCOvwRxGd|C@XauBb2I<&)#;OEg(oOik6KFfkx z6obfmi!Z~`SCs7PgIEE8ueQV%;To=XmrWtcT;7OgNX(QCby$Fu+59X*RFrYDzlp&o z@5POM!<1;>j+o0%i^XJHLiC82?kJWV!MsCku~2w<5nY@=cQItg>)TIAX=%Ms5t1h5 z%fd;z7uVZ+k;6|3(~rUq^!381-R>yt$r$i|RRvXkLJAxd4r7RND z!C5MsQ@o$;**640q{Dt)x_{&o+X`HycYoPxa}7Ah8AABe*{f7g!ejZg7OTgGyS&}6 z$=tg|5@hbJOU@D;F09#6sLs}^F1)5kb#{Un<#QgKJwTwWHXGB*=M z2#P3w8KVREtCTfl`}cwv3blMa2vWKC@Xr@>)8|N`vki4y^24^Q)Rg$J znJp*|i=+$Cn)80`qUhWPQWVi@ d7V2w~yCa5NLLH0s3tLA))ww#ftWVF&@8FKqp z{_34cWlIPiG1}Z^@)BV}@E&}!95gq_NI%@^rF|Fpq8IvVd!A2MjQj*Z(|a=R5U*v? zzeb5wyFU^dKL;65HlBDDXzmiiU%{1uSpc_H`B)&^BD0@4dz6L|W$7GeRyiff{?5cL zEBeK(`AvK6y=@A*tF@63$iYx(b*NQztQ3;;cO&obMn!^MRO-pr#FpclIRC%wa^e4dgbJ4h|Fz-H*BzTm&1vHOpHa8_c)Iw{iJH^@#&$AFS$!erF zN3?g>tJ;&9{+3d7}B>k$81Z2GljcL}E&ZfPF<6XCBApIJDPMP1{o(QDwI z{f;&q6pAG>nmF4DR_OQYXky;a|9w&0n_9IyE`KD75SdR{)z>PURM%a!e>|)g942Jg zvPm7DsE^t~$hE#eQXlr7Fy`UK7Y@;#)$_U?YCdoUh02Canbz`4`Q%gM;$w0x0;&2D zZ(8p~Mab@MgrdZc7iwP}{fzwW!GIfd2KzR;q8ZWB%lbFAQ~7ipfA7n71JIuKt5L-e z^Q4LW)M+~!nSU~DcT@h8&eu?!NlzodV#SinG_wr!J~2Iul>irW?LBEGYhMXTk^8*v zX@`!%&{#m5^b;nQO5{RJ=6e4*%r>P)0TYMJWGOl|x{Sv0Yh>EN1ok4fF2MLjGDkEG z=A$dW4ozcc@28%MnNKNs080A!>;hZFZ+p-^WXu0qMBk~xEIJR5jZa*?UB37)x3`$eLm{f3ku zwTX@Yj?(*1LX45Mes^6$(U4o{BClpLx(t^8T3=Sn(+1_lHWE$kv~qL_mQ7> zUp`A9Yt%On5H+lY!yU$^rMwmujcAd!5<##PJ3^XHIM|%JMa^y^RbgsnxIsU%k`VqA z|CjuxH%@xmJnhm{{|}$5bqb;T@yYk%ekLUt*%_3q0H3894{JHU>14XqyC2JoJ~~=Q z^MvqDwu|#>vt7xiL5}0U7 zWp0=l#8Q(bKAk1pk_oR>H+80Bs;F$h(;e<`OuyosgBeD1_oFFrB%yeXfF~?6eQR&6 zf5AgR(f3fP>edZmX<4P~lyG)KN_n+Ec4hk$(9=}G)L2$C!zSkzXN?pw;MGUGc<3b1g|fB!gl8xkWPLI zd{@wsBr^9Z+78uIZ#BN7-yZ9**O|aRza1ZB7)ZK3jdK50()I)`%jZ*PY4w5=K4vm< z|Gqwy)?)R;w;~_|>Zgo{qOac)evaqZQOA#M!zX`z>UMO~0(;@3VBY};t92e~V zb4aQh0mQ?>tkt-5({j^uc6gBry!=e9_SCauCE(SML}#sfc}e||(yAA3@TJvUe5mS8 z>cR(2F&`c=2<9$;LMwAy<#kQ2J?~rQuA0`6<9-E^oLZDX&v}=ivkY5bw{c3XVaZG4 z*n}~--0qVi8}ZOG-43ObOF8->1~2d9H!WK}P1VOjdHcF6_p5{-TF8MZfP12(Tf5{B z9-(wrUh9$>RQ1m*Bv{ig!G>@?@{jsdUKn9noo)p~>uvanI*zW(Zo8`RRwG;;huOC} zzIBDY4Wi`o{@Ap5wPrHC$9_M1+Q!sQ-7KJ6=~|{I@j25uumhaOLh=E>vM4h!#w{jT zZD>E|I&`nAF=n?I?3dg90Pe9##{aCwj1Iq?@3E-#wep5ys%kVaM<29kNW=`}ZmAxm zZhnw=+(-Qb`}Kw^8BAfHwV{#)OiQFUPS52EYixI#m7Yx33mfbg)$Sdtc?)ho+2SGS z?k5U+)~dhd`og?ad#d)Jv_6k$Tn;6|fWv9NzIAI8vi(Z60*6(qeR``--?LeJy*yGu zbQLzAZ526Y>+IT5Q!>1F<$J?Re**qxC~A*SYx~47Ft5`8G89-ieE9zb{YlJ}(%^sL z$^X3nhw(q^{~vcrN}!bD_!sp5|B-+Ai~j$YWrF!f|G%T isset($p['base_price']) ? (float) $p['base_price'] : 0.0, 'norm' => $norm, + 'a' => $a, + 'e' => $e, 'formel' => $formel, 'mwst' => isset($s['mwst_percent']) ? (float) $s['mwst_percent'] : 19.0, 'tiers' => $tiers, @@ -191,6 +196,7 @@ function skrift_preis_rechne_param($par, $menge, $mitKuvert) { $mult = skrift_formel_eval($par['formel'], array( 'qty' => $menge, 'menge' => $menge, 'norm' => $par['norm'], 'base_price' => $par['base'], + 'a' => $par['a'], 'e' => $par['e'], ), 1.0); } if (!is_finite($mult) || $mult <= 0) $mult = 1.0; @@ -377,7 +383,7 @@ function skrift_preis_render($atts, $mitMwst) { function berechne(m, mitKuvert) { m = Math.max(1, parseInt(m, 10) || 1); var mult = par.norm > 0 - ? formelEval(par.formel, { qty: m, menge: m, norm: par.norm, base_price: par.base }) + ? formelEval(par.formel, { qty: m, menge: m, norm: par.norm, base_price: par.base, a: par.a, e: par.e }) : 1; if (!isFinite(mult) || mult <= 0) mult = 1; var grund = par.base * m * mult;