Produktion: Format-Warteschlangen, Sammeldruck, Webhook, Preis-a/e

Produktions-Board:
- Massenauswahl (+ "Alle auswählen") mit Sammel-Druck und Sammel-Status
- Warteschlange je Maschine nach Format gebündelt; Formatabfrage
  (machines.current_format) statt automatischem Anschluss
- resiliente Ladeabfragen (Board bleibt ohne neue Felder nutzbar)

Agent (skrift-agent):
- druckt strikt ein Dokument nach dem anderen (Idle-Gate) -> behebt
  Verlust von Folgejobs während eines laufenden Drucks
- druckt nur Jobs des eingelegten Formats (current_format), kein Auto-Anschluss
- Heartbeat gedrosselt (nur bei Statuswechsel / alle 60 s)

Neues Modul "Manueller Druck" (skrift-neuauftrag):
- interner Auftrag ohne Zahlung, Vorschau, Platzhalter/Excel-Import,
  Felder bleiben nach dem Absenden stehen

Backend/Endpunkte (skrift-orders):
- jobs tragen format/format_label; /enqueue-bulk für Sammeldruck
- Webhook /inbound/:kind (JetFormBuilder) mit Token + tolerantem Body-Parser
- Kleinmengen-Aufschlag je Produkt über Faktoren a (Deckel) und e (Exponent)
- Fix: norm()-Shadowing in calculatePrice (norm is not a function)

Bootstrap:
- jobs.format/format_label, machines.current_format
- products.price_factor_a/e; Multiplikator-Formel auf a/e umgestellt inkl.
  verhaltensneutraler Migration bestehender Formel/Produkte

skrift-app (Konfigurator):
- Zeilenumbrüche im Textfeld erlaubt; Umschlag-Individualtext als Textfeld
- Platzhalter-Tabelle im Text-Schritt immer verfügbar (auch Sammelversand
  ohne Kuvert) inkl. Excel-Upload nur für Platzhalterwerte
- Danke-Seite: Button zurück zu skrift.de
- z-index-Fix: Platzhalter-Tabelle überlagert die Vorschau nicht mehr

WordPress-Preisrechner: a/e-Faktoren in PHP- und JS-Rechnung
Directus: SKRIFT_WEBHOOK_TOKEN in compose/.env.example

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-26 11:51:24 +02:00
parent f77ddb6a4c
commit 6232cecdb1
17 changed files with 1239 additions and 97 deletions

View File

@@ -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).