From 1b2541e307d431e625df923cc0aaca4c5a7bff64 Mon Sep 17 00:00:00 2001 From: s4luorth Date: Thu, 3 Sep 2026 12:10:28 +0200 Subject: [PATCH] Kundenangebote (customer_offers) + Absender-Uebernahme-Fix - Bootstrap: Collection customer_offers (Kunde+Produkt, Name-Zusatz, Aufschlaege/Stueck, mehrseitig erlauben, Notiz) + Order-Felder customer_offer/product_name_suffix. - orders: calculatePrice addiert Angebots-Aufschlaege (serverseitig geprueft), Endpunkt /my-offers, POST / speichert Angebot + Name-Zusatz. - produktion: 'In manuellen Druck uebernehmen' traegt jetzt auch Kuvert-Absender + Groesse mit. - neuauftrag: uebernimmt Absender-Felder aus dem Auftrag. Co-Authored-By: Claude Opus 4.8 --- bootstrap/bootstrap.mjs | 19 ++++++++ .../dist/index.js | 3 ++ .../dist/index.js | 47 ++++++++++++++++++- .../dist/index.js | 5 +- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/bootstrap/bootstrap.mjs b/bootstrap/bootstrap.mjs index 7b75699..e37a590 100644 --- a/bootstrap/bootstrap.mjs +++ b/bootstrap/bootstrap.mjs @@ -286,6 +286,8 @@ const collections = [ f('own_cards', 'boolean', { interface: 'boolean', default: false, note: 'Eigene Karten (≤ 240 g/m²).' }), f('text_briefing', 'text', { interface: 'input-multiline', note: 'Nur bei Schreibservice.' }), f('text_template', 'text', { interface: 'input-multiline', note: 'Text mit [[Platzhaltern]].' }), + f('customer_offer', 'integer', { interface: 'input', note: 'ID des gewählten Kundenangebots (Aufschläge sind im Preis).' }), + f('product_name_suffix', 'string', { interface: 'input', note: 'Namens-Zusatz aus dem Kundenangebot, z. B. „auf bedrucktem Papier".' }), f('preview_session', 'string', { interface: 'input', note: 'Vorschau-Session – gezeigte Dokumente werden 1:1 übernommen.' }), // Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF/Word). { field: 'source_file', type: 'uuid', @@ -516,6 +518,23 @@ const collections = [ { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, ] }, + // Kundenspezifische Angebote: frei benannte Aufschläge (pro Stück) + Namens-Zusatz, + // die der eingeloggte Kunde im Konfigurator auswählt. Kein Festpreis (Phase 2). + { collection: 'customer_offers', meta: { icon: 'local_offer', note: 'Kundenangebote: Aufschläge/Name-Zusatz je Kunde+Produkt (Kunde wählt im Konfigurator).' }, fields: [ + m2o('customer', 'directus_users', { onDelete: 'CASCADE' }), + m2o('product', 'products', { onDelete: 'CASCADE' }), + f('label', 'string', { interface: 'input', note: 'Angezeigte Bezeichnung, z. B. „Auf bedrucktem Papier".' }), + f('name_suffix', 'string', { interface: 'input', note: 'Zusatz an den Produktnamen, z. B. „auf bedrucktem Papier".' }), + f('surcharges', 'json', { interface: 'list', note: 'Aufschläge pro Stück (netto): Bezeichnung + Betrag.', options: { fields: [ + { field: 'label', type: 'string', name: 'Bezeichnung', meta: { interface: 'input', width: 'half', options: { placeholder: 'z. B. bedrucktes Papier' } } }, + { field: 'amount', type: 'float', name: 'Betrag netto (€/Stück)', meta: { interface: 'input', width: 'half' } }, + ] } }), + f('allow_multi_page', 'boolean', { interface: 'boolean', default: false, note: 'Mehrseitigen Druck erlauben.' }), + f('note', 'text', { interface: 'input-multiline', note: 'Hinweis an den Kunden (optional).' }), + f('active', 'boolean', { interface: 'boolean', default: true }), + { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, + ] }, + // E-Mail-Vorlagen (Betreff + HTML-Text, WYSIWYG). Ein Datensatz je Ereignis. { collection: 'email_templates', meta: { icon: 'mail', note: 'E-Mail-Vorlagen (Betreff + Text).' }, fields: [ f('key', 'string', { interface: 'input', unique: true, required: true, note: 'Ereignis-Schlüssel (login_code, order_created, versendet …).' }), diff --git a/extensions/directus-extension-skrift-neuauftrag/dist/index.js b/extensions/directus-extension-skrift-neuauftrag/dist/index.js index a9558ef..a447457 100644 --- a/extensions/directus-extension-skrift-neuauftrag/dist/index.js +++ b/extensions/directus-extension-skrift-neuauftrag/dist/index.js @@ -128,6 +128,9 @@ const NeuAuftrag = defineComponent({ if (d.fontSizeLetter) neu.fontSizeLetter = d.fontSizeLetter; if (d.fontSizeEnvelope) neu.fontSizeEnvelope = d.fontSizeEnvelope; if (typeof d.kuverts === 'boolean') neu.kuverts = d.kuverts; + if (typeof d.absenderAufKuvert === 'boolean') neu.absenderAufKuvert = d.absenderAufKuvert; + if (typeof d.envelopeSender === 'string') neu.envelopeSender = d.envelopeSender; + if (d.envelopeSenderScale) neu.envelopeSenderScale = d.envelopeSenderScale; if (d.adressModus) neu.adressModus = d.adressModus; if (Array.isArray(d.rows) && d.rows.length) { neu.rows = d.rows.map((r) => ({ diff --git a/extensions/directus-extension-skrift-orders/dist/index.js b/extensions/directus-extension-skrift-orders/dist/index.js index 71657b6..ca4e70b 100644 --- a/extensions/directus-extension-skrift-orders/dist/index.js +++ b/extensions/directus-extension-skrift-orders/dist/index.js @@ -527,6 +527,25 @@ const handler = (router, { services, getSchema, logger }) => { let net = lines.reduce((s, l) => s + l.amount, 0); // 5) Gutschein + // Kundenangebot (customer_offers): frei benannte Aufschläge pro Stück, wenn der + // eingeloggte Kunde ein Angebot gewählt hat. Serverseitig geprüft (Zugehörigkeit). + let offer = null; + if (input.offer) { + const off = (await svc('customer_offers').readByQuery({ + filter: { id: { _eq: input.offer }, active: { _eq: true } }, limit: 1, + fields: ['id', 'customer', 'product.key', 'name_suffix', 'surcharges', 'allow_multi_page'], + }))?.[0]; + // Nur anwenden, wenn das Angebot dem anfragenden Kunden UND dem Produkt gehört. + if (off && input.customerId && String(off.customer) === String(input.customerId) + && (!off.product?.key || off.product.key === input.product)) { + offer = off; + for (const s of (Array.isArray(off.surcharges) ? off.surcharges : [])) { + const betrag = Number(s?.amount) || 0; + if (betrag) { const gesamt = betrag * menge; add(s.label || 'Aufschlag', gesamt, true); net += gesamt; } + } + } + } + let voucher = null; if (input.voucher_code) { const v = (await svc('vouchers').readByQuery({ @@ -556,7 +575,7 @@ const handler = (router, { services, getSchema, logger }) => { const paypalAllowed = net < limit; return { product, lines, net_total: net, unit_net_total: unitNet, vat_amount: vat, gross_total: gross, - mwst_percent: mwst, paypal_allowed: paypalAllowed, paypal_limit_net: limit, voucher }; + mwst_percent: mwst, paypal_allowed: paypalAllowed, paypal_limit_net: limit, voucher, offer }; } function validate(body) { @@ -578,6 +597,7 @@ const handler = (router, { services, getSchema, logger }) => { const body = req.body || {}; const quote = await calculatePrice(schema, { ...body, quantity: body.quantity ?? (body.entries || []).length, + customerId: req.accountability?.user || null, }); return res.json({ lines: quote.lines, net_total: quote.net_total, unit_net_total: quote.unit_net_total, @@ -590,6 +610,26 @@ const handler = (router, { services, getSchema, logger }) => { } }); + // ── Angebote des eingeloggten Kunden (Auswahl im Konfigurator) ──────────── + router.get('/my-offers', async (req, res) => { + const uid = req.accountability?.user; + if (!uid) return res.json({ offers: [] }); + try { + const schema = await getSchema(); + const svc = (c) => new ItemsService(c, { schema, accountability: null }); + const filter = { customer: { _eq: uid }, active: { _eq: true } }; + if (req.query.product) filter.product = { key: { _eq: String(req.query.product) } }; + const offers = await svc('customer_offers').readByQuery({ + filter, limit: -1, sort: ['label'], + fields: ['id', 'label', 'name_suffix', 'note', 'allow_multi_page', 'surcharges', 'product.key', 'product.name'], + }); + return res.json({ offers: offers || [] }); + } catch (err) { + logger.error(`[skrift-orders] my-offers: ${err.stack || err.message}`); + return res.status(500).json({ error: 'Angebote konnten nicht geladen werden.' }); + } + }); + // ── Vorschau ───────────────────────────────────────────────────────────── /** * Erzeugt bis zu PREVIEW_MAX_DOCS Vorschau-SVGs über das Backend. @@ -1682,7 +1722,7 @@ const handler = (router, { services, getSchema, logger }) => { const { settings } = await loadPricingContext(schema); const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E'); - const quote = await calculatePrice(schema, { ...body, quantity: entries.length }); + const quote = await calculatePrice(schema, { ...body, quantity: entries.length, customerId: req.accountability?.user || null }); // Zahlungsart erzwingen, wenn über dem PayPal-Limit let paymentMethod = body.payment_method === 'paypal' ? 'paypal' : 'rechnung'; @@ -1774,6 +1814,9 @@ const handler = (router, { services, getSchema, logger }) => { customer: customer.id, customer_email: email, product: quote.product.id, + // Gewähltes Kundenangebot (Aufschläge sind bereits im Preis) + Namens-Zusatz. + customer_offer: quote.offer?.id ?? null, + product_name_suffix: quote.offer?.name_suffix ?? null, format: formatId, person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat', font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda', diff --git a/extensions/directus-extension-skrift-produktion/dist/index.js b/extensions/directus-extension-skrift-produktion/dist/index.js index 80db6bc..d22858d 100644 --- a/extensions/directus-extension-skrift-produktion/dist/index.js +++ b/extensions/directus-extension-skrift-produktion/dist/index.js @@ -727,7 +727,7 @@ const Board = defineComponent({ if (!o) return; try { const full = (await api.get('/items/orders/' + o.id, { params: { - fields: 'text_template,font,multi_page,realistic,font_scale_letter,font_scale_envelope,needs_envelope,product.key,format.key', + fields: 'text_template,font,multi_page,realistic,font_scale_letter,font_scale_envelope,needs_envelope,envelope_sender,envelope_sender_scale,product.key,format.key', } })).data?.data || {}; const ent = (await api.get('/items/order_entries', { params: { 'filter[order][_eq]': o.id, sort: 'letter_number', limit: -1, fields: 'free_text,placeholders,letter_number', @@ -746,6 +746,9 @@ const Board = defineComponent({ fontSizeLetter: Math.round((Number(full.font_scale_letter) || 1) * 100), fontSizeEnvelope: Math.round((Number(full.font_scale_envelope) || 1) * 100), kuverts: !!full.needs_envelope, + absenderAufKuvert: !!(full.envelope_sender && String(full.envelope_sender).trim()), + envelopeSender: full.envelope_sender || '', + envelopeSenderScale: Math.round((Number(full.envelope_sender_scale) || 1) * 100), adressModus: rows.some((r) => r.free_text.includes('\n')) ? '5zeilen' : 'individuell', rows, };