Directus als Daten-/Auth-Schicht + Backend-Anbindung
Directus (Postgres, hinter NPM) wird die zentrale Datenquelle für Kunden, Aufträge, Produkte, Preise und Gutscheine; das skrift-backend bleibt reine Generierungs-Engine. - Docker/directus: docker-compose (Directus 11 + Postgres), Bootstrap-Skript legt Collections, Rollen/Policies/Rechte und Seed idempotent an. - Extension skrift-auth: passwortloser Login per E-Mail-Code (Codes nur als Hash, Ablauf, Versuchslimit) sowie Kontoanlage aus dem Checkout. - Extension skrift-orders: kontrollierter Bestell-Endpunkt für Gäste und eingeloggte Kunden; Preis wird immer serverseitig berechnet (editierbare Formel via sicherem Interpreter, Werte aus price_items). - skrift-backend: neuer Endpunkt /api/order/from-directus erzeugt SVGs aus einem Directus-Auftrag und schreibt Status + artifact_path zurück. Dafür wurde die Kernlogik aus generateOrder als runGeneration herausgelöst (Verhalten unverändert); generateOrder ist jetzt ein HTTP-Wrapper. - compose: NPM-Netz ergänzt, API_TOKEN/DIRECTUS_* werden durchgereicht. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
349
Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js
vendored
Normal file
349
Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js
vendored
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Skrift – Bestell-Endpunkt
|
||||
* ---------------------------------------------------------------------------
|
||||
* POST /skrift-orders Auftrag anlegen (Gast oder eingeloggt)
|
||||
* POST /skrift-orders/quote Nur Preis berechnen (für die Live-Anzeige oben)
|
||||
*
|
||||
* Grundsatz: Dem Client wird beim Preis NICHT vertraut. Der Betrag wird immer
|
||||
* serverseitig aus products/price_items/pricing_settings neu berechnet.
|
||||
*
|
||||
* Preislogik:
|
||||
* - Grundpreis → editierbare Formel (pricing_settings.formula), ausgewertet
|
||||
* von einem sicheren Mini-Interpreter (kein eval).
|
||||
* - Aufschläge → Versand, Kuvert, Beschriftung, Zusatzleistungen: Struktur im
|
||||
* Code, Werte aus price_items.
|
||||
* - Zahlung → ab pricing_settings.paypal_limit_net (netto) nur noch Rechnung.
|
||||
*/
|
||||
|
||||
// ── Sicherer Formel-Interpreter ─────────────────────────────────────────────
|
||||
// Erlaubt: Zahlen, Variablen, + - * / ( ) und unäres Minus. Sonst nichts.
|
||||
function evalFormula(expr, vars) {
|
||||
const tokens = String(expr).match(/\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_]*|[+\-*/()]/g);
|
||||
if (!tokens) throw new Error('Formel ist leer oder ungültig.');
|
||||
|
||||
let pos = 0;
|
||||
const peek = () => tokens[pos];
|
||||
const next = () => tokens[pos++];
|
||||
|
||||
// Ausdruck := Term (('+'|'-') Term)*
|
||||
function parseExpr() {
|
||||
let left = parseTerm();
|
||||
while (peek() === '+' || peek() === '-') {
|
||||
const op = next();
|
||||
const right = parseTerm();
|
||||
left = op === '+' ? left + right : left - right;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
// Term := Faktor (('*'|'/') Faktor)*
|
||||
function parseTerm() {
|
||||
let left = parseFactor();
|
||||
while (peek() === '*' || peek() === '/') {
|
||||
const op = next();
|
||||
const right = parseFactor();
|
||||
if (op === '/' && right === 0) throw new Error('Division durch 0 in der Preisformel.');
|
||||
left = op === '*' ? left * right : left / right;
|
||||
}
|
||||
return left;
|
||||
}
|
||||
// Faktor := '-' Faktor | '(' Ausdruck ')' | Zahl | Variable
|
||||
function parseFactor() {
|
||||
const t = next();
|
||||
if (t === undefined) throw new Error('Formel unvollständig.');
|
||||
if (t === '-') return -parseFactor();
|
||||
if (t === '(') {
|
||||
const v = parseExpr();
|
||||
if (next() !== ')') throw new Error('Fehlende schließende Klammer.');
|
||||
return v;
|
||||
}
|
||||
if (/^\d/.test(t)) return parseFloat(t);
|
||||
if (Object.prototype.hasOwnProperty.call(vars, t)) return Number(vars[t]) || 0;
|
||||
throw new Error(`Unbekannte Variable in der Preisformel: "${t}"`);
|
||||
}
|
||||
|
||||
const result = parseExpr();
|
||||
if (pos !== tokens.length) throw new Error('Formel konnte nicht vollständig gelesen werden.');
|
||||
if (!Number.isFinite(result)) throw new Error('Formel ergibt keinen gültigen Betrag.');
|
||||
return result;
|
||||
}
|
||||
|
||||
const round2 = (n) => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
|
||||
|
||||
const handler = (router, { services, getSchema, logger }) => {
|
||||
const { ItemsService } = services;
|
||||
|
||||
const norm = (e) => String(e || '').trim().toLowerCase();
|
||||
const isEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
||||
|
||||
/** Entfernt alle nicht erlaubten Zeichen (Whitelist aus pricing_settings). */
|
||||
function makeSanitizer(whitelist) {
|
||||
let re;
|
||||
try { re = new RegExp(`[^${whitelist}\\n\\r\\t]`, 'g'); }
|
||||
catch { re = null; }
|
||||
return (v) => (typeof v === 'string' && re ? v.replace(re, '') : v);
|
||||
}
|
||||
|
||||
async function loadPricingContext(schema) {
|
||||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||
const [settingsRaw, priceItems] = await Promise.all([
|
||||
svc('pricing_settings').readSingleton({}),
|
||||
svc('price_items').readByQuery({ filter: { active: { _eq: true } }, limit: -1,
|
||||
fields: ['key', 'price', 'unit', 'category'] }),
|
||||
]);
|
||||
const settings = settingsRaw || {};
|
||||
const byKey = Object.fromEntries((priceItems || []).map((p) => [p.key, p]));
|
||||
return { settings, priceItems: byKey };
|
||||
}
|
||||
|
||||
/** Berechnet den Preis vollständig serverseitig. */
|
||||
async function calculatePrice(schema, input) {
|
||||
const { settings, priceItems } = await loadPricingContext(schema);
|
||||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||
|
||||
const product = (await svc('products').readByQuery({
|
||||
filter: { key: { _eq: input.product } }, limit: 1,
|
||||
fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'active'],
|
||||
}))?.[0];
|
||||
if (!product || !product.active) throw new Error('Unbekanntes oder inaktives Produkt.');
|
||||
if (product.pricing_mode === 'auf_anfrage')
|
||||
throw new Error(`"${product.name}" ist nur auf Anfrage bestellbar.`);
|
||||
|
||||
const menge = Math.max(0, parseInt(input.quantity, 10) || 0);
|
||||
if (menge < 1) throw new Error('Menge muss mindestens 1 sein.');
|
||||
|
||||
const lines = [];
|
||||
const add = (label, amount) => { if (amount) lines.push({ label, amount: round2(amount) }); };
|
||||
const priceOf = (key) => Number(priceItems[key]?.price) || 0;
|
||||
const unitOf = (key) => priceItems[key]?.unit || 'einmalig';
|
||||
const applyUnit = (key) => (unitOf(key) === 'pro_stueck' ? priceOf(key) * menge : priceOf(key));
|
||||
|
||||
// 1) Grundpreis über die editierbare Formel
|
||||
const formula = settings.formula || 'base_price * menge';
|
||||
const grund = evalFormula(formula, { base_price: Number(product.base_price) || 0, menge });
|
||||
add(`${product.name} (${menge} Stk.)`, grund);
|
||||
|
||||
// 2) Versand – Kuvert ist bei Einzelversand Pflicht
|
||||
if (input.shipping_type === 'einzeln') {
|
||||
const auslandCount = (input.entries || [])
|
||||
.filter((e) => e.country && !/^(de|deutschland|germany)$/i.test(String(e.country).trim())).length;
|
||||
const inlandCount = menge - auslandCount;
|
||||
add('Porto Inland', priceOf('porto_inland') * inlandCount);
|
||||
add('Porto Ausland', priceOf('porto_ausland') * auslandCount);
|
||||
} else {
|
||||
add('Sammelversand', priceOf('sammelversand'));
|
||||
}
|
||||
|
||||
// 3) Kuvert + Beschriftung
|
||||
if (input.needs_envelope) {
|
||||
add('Kuvert', applyUnit('kuvert'));
|
||||
if (input.envelope_labeling && input.envelope_labeling !== 'keine')
|
||||
add('Kuvert-Beschriftung', applyUnit('beschriftung'));
|
||||
}
|
||||
|
||||
// 4) Motiv-Upload + Zusatzleistungen (Schreib-/Gestaltungsservice usw.)
|
||||
if (input.motif_mode === 'upload') add('Motiv-Upload', applyUnit('motiv_upload'));
|
||||
for (const key of input.addons || []) {
|
||||
const item = priceItems[key];
|
||||
if (!item || item.category !== 'zusatzleistung') continue;
|
||||
add(item.key, applyUnit(key));
|
||||
}
|
||||
|
||||
let net = lines.reduce((s, l) => s + l.amount, 0);
|
||||
|
||||
// 5) Gutschein
|
||||
let voucher = null;
|
||||
if (input.voucher_code) {
|
||||
const v = (await svc('vouchers').readByQuery({
|
||||
filter: { code: { _eq: String(input.voucher_code).trim() }, active: { _eq: true } },
|
||||
limit: 1, fields: ['id', 'code', 'type', 'value', 'valid_until', 'max_uses', 'used_count'],
|
||||
}))?.[0];
|
||||
const expired = v?.valid_until && new Date(v.valid_until).getTime() < Date.now();
|
||||
const exhausted = v?.max_uses != null && (v.used_count ?? 0) >= v.max_uses;
|
||||
if (v && !expired && !exhausted) {
|
||||
const discount = v.type === 'prozent' ? net * (Number(v.value) / 100) : Number(v.value);
|
||||
const capped = Math.min(discount, net);
|
||||
add(`Gutschein ${v.code}`, -capped);
|
||||
net -= capped;
|
||||
voucher = v;
|
||||
}
|
||||
}
|
||||
|
||||
net = round2(Math.max(0, net));
|
||||
const mwst = Number(settings.mwst_percent ?? 19);
|
||||
const vat = round2(net * (mwst / 100));
|
||||
const gross = round2(net + vat);
|
||||
|
||||
// 6) Zahlungsart: ab Limit nur noch Rechnung
|
||||
const limit = Number(settings.paypal_limit_net ?? 200);
|
||||
const paypalAllowed = net < limit;
|
||||
|
||||
return { product, lines, net_total: net, vat_amount: vat, gross_total: gross,
|
||||
mwst_percent: mwst, paypal_allowed: paypalAllowed, paypal_limit_net: limit, voucher };
|
||||
}
|
||||
|
||||
function validate(body) {
|
||||
const errors = [];
|
||||
if (!isEmail(norm(body?.email))) errors.push('Gültige E-Mail-Adresse fehlt.');
|
||||
if (!body?.product) errors.push('Produkt fehlt.');
|
||||
const entries = Array.isArray(body?.entries) ? body.entries : [];
|
||||
if (entries.length < 1) errors.push('Mindestens eine Empfängerzeile erforderlich.');
|
||||
// Kuvert-Pflicht bei Einzelversand (ohne Kuvert → Lieferung an den Besteller)
|
||||
if (body?.shipping_type === 'einzeln' && !body?.needs_envelope)
|
||||
errors.push('Bei Einzelversand an die Empfänger ist ein Kuvert erforderlich.');
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ── Nur Preis berechnen (Live-Anzeige) ───────────────────────────────────
|
||||
router.post('/quote', async (req, res) => {
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const body = req.body || {};
|
||||
const quote = await calculatePrice(schema, {
|
||||
...body, quantity: body.quantity ?? (body.entries || []).length,
|
||||
});
|
||||
return res.json({
|
||||
lines: quote.lines, net_total: quote.net_total, vat_amount: quote.vat_amount,
|
||||
gross_total: quote.gross_total, mwst_percent: quote.mwst_percent,
|
||||
paypal_allowed: quote.paypal_allowed,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Auftrag anlegen ──────────────────────────────────────────────────────
|
||||
router.post('/', async (req, res) => {
|
||||
const body = req.body || {};
|
||||
const errors = validate(body);
|
||||
if (errors.length) return res.status(400).json({ errors });
|
||||
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||
const email = norm(body.email);
|
||||
const entries = body.entries;
|
||||
|
||||
const { settings } = await loadPricingContext(schema);
|
||||
const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
|
||||
|
||||
const quote = await calculatePrice(schema, { ...body, quantity: entries.length });
|
||||
|
||||
// Zahlungsart erzwingen, wenn über dem PayPal-Limit
|
||||
let paymentMethod = body.payment_method === 'paypal' ? 'paypal' : 'rechnung';
|
||||
if (paymentMethod === 'paypal' && !quote.paypal_allowed) paymentMethod = 'rechnung';
|
||||
|
||||
// Kunde finden oder anlegen – Zuordnung immer per E-Mail
|
||||
const customers = svc('customers');
|
||||
let customer = (await customers.readByQuery({
|
||||
filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
|
||||
}))?.[0];
|
||||
if (!customer) {
|
||||
const id = await customers.createOne({
|
||||
email,
|
||||
person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
|
||||
first_name: clean(body.first_name) ?? null,
|
||||
last_name: clean(body.last_name) ?? null,
|
||||
company: clean(body.company) ?? null,
|
||||
phone: clean(body.phone) ?? null,
|
||||
});
|
||||
customer = { id };
|
||||
}
|
||||
|
||||
// Format auflösen (optional)
|
||||
let formatId = null;
|
||||
if (body.format) {
|
||||
formatId = (await svc('formats').readByQuery({
|
||||
filter: { key: { _eq: body.format } }, limit: 1, fields: ['id'],
|
||||
}))?.[0]?.id ?? null;
|
||||
}
|
||||
|
||||
// Umschlagformat automatisch: A4 → DIN Lang, A6 → C6
|
||||
const envelopeFormat = body.needs_envelope
|
||||
? (String(body.format || '').startsWith('a4') ? 'dinlang' : 'c6')
|
||||
: null;
|
||||
|
||||
const orderNumber = `SK-${new Date().toISOString().slice(0, 10)}-${
|
||||
String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`;
|
||||
|
||||
const orderId = await svc('orders').createOne({
|
||||
order_number: orderNumber,
|
||||
customer: customer.id,
|
||||
customer_email: email,
|
||||
product: quote.product.id,
|
||||
format: formatId,
|
||||
person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
|
||||
font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda',
|
||||
source: body.source === 'operator' ? 'operator' : (body.source === 'portal' ? 'portal' : 'configurator'),
|
||||
status: 'wartet_auf_zahlung',
|
||||
shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
|
||||
needs_envelope: !!body.needs_envelope,
|
||||
envelope_labeling: body.envelope_labeling ?? null,
|
||||
envelope_format: envelopeFormat,
|
||||
motif_mode: body.motif_mode ?? null,
|
||||
own_cards: body.motif_mode === 'eigene_karten',
|
||||
text_template: clean(body.text_template) ?? null,
|
||||
text_briefing: clean(body.text_briefing) ?? null,
|
||||
entries_count: entries.length,
|
||||
net_total: quote.net_total,
|
||||
vat_amount: quote.vat_amount,
|
||||
gross_total: quote.gross_total,
|
||||
voucher: quote.voucher?.id ?? null,
|
||||
payment_method: paymentMethod,
|
||||
payment_status: 'offen',
|
||||
});
|
||||
|
||||
// Empfängerzeilen – Briefnummer wird IMMER vom System vergeben
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Zusatzleistungen verknüpfen
|
||||
if (Array.isArray(body.addons) && body.addons.length) {
|
||||
const items = await svc('price_items').readByQuery({
|
||||
filter: { key: { _in: body.addons }, category: { _eq: 'zusatzleistung' } },
|
||||
limit: -1, fields: ['id'],
|
||||
});
|
||||
for (const it of items || []) await svc('order_addons').createOne({ order: orderId, price_item: it.id });
|
||||
}
|
||||
|
||||
// Gutschein-Einlösung protokollieren
|
||||
if (quote.voucher) {
|
||||
await svc('voucher_redemptions').createOne({ voucher: quote.voucher.id, order: orderId });
|
||||
await svc('vouchers').updateOne(quote.voucher.id, { used_count: (quote.voucher.used_count ?? 0) + 1 });
|
||||
}
|
||||
|
||||
await svc('status_history').createOne({
|
||||
order: orderId, status: 'wartet_auf_zahlung', note: 'Auftrag über den Konfigurator angelegt.',
|
||||
});
|
||||
|
||||
return res.json({
|
||||
order_number: orderNumber,
|
||||
net_total: quote.net_total, vat_amount: quote.vat_amount, gross_total: quote.gross_total,
|
||||
payment_method: paymentMethod, paypal_allowed: quote.paypal_allowed,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-orders] ${err.stack || err.message}`);
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default { id: 'skrift-orders', handler };
|
||||
Reference in New Issue
Block a user