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

@@ -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,

View File

@@ -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,
};

View File

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

View File

@@ -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(',');