Produktions-Modul, überarbeitete Preislogik und WordPress-Preisrechner

Directus
- Neues Modul „Produktion": Board mit Neu/Warteschlange/Im Druck/
  Kuvertierung/Versandfertig/Versendet/Archiv, zentraler Suche, Druck-Popup
  (Maschine, Template, Maße, Nummernbereich, Direktdruck vs. Warteschlange)
  und Empfängerliste mit Vorschau von Schriftstück und Kuvert.
- Interface-Plugins: Auftragsdateien, Drucken, Testdruck, Empfängersuche.
- Mailer-Hook für Auftrags- und Statusmails inkl. Signatur und AGB-Anhang.
- Auftrag: Empfängerzeilen inline, Produktionsstatus (inkl. storniert),
  Versanddatum, Brief-/Kuvert-Indikatoren, Insights-Dashboard.
- Absender aller Mails ist Skrift (Projektname + email_settings); die
  Angebotsanfrage nennt jetzt das angefragte Produkt.

Preislogik
- Eine gemeinsame Multiplikator-Formel; Normalpreis-Menge je Produkt
  (products.norm_qty). Alte B2B/B2C-Fallback-Felder entfernt.
- Mengenrabatt-Staffel (ab X Stück Y %) in den Preis-Einstellungen.
- Preis pro Stück ohne einmalige Kosten (unit_net_total).
- Einzelversand rechnet Kuvert und Beschriftung nicht mehr doppelt.
- Fehlerhafte Grundpreis-Formel legt die Preisauskunft nicht mehr lahm.
- Briefnummern 1-basiert: Dateiname entspricht der Briefnummer.

app.skrift.de
- Direktlinks für Muster, Unterschriftenservice und Follow-ups; Muster als
  eigene Seite, Kontaktprodukte direkt im Anfrageformular.
- Mehrere Adressen in den Stammdaten plus Adressauswahl im Konfigurator.
- Verbotene Zeichen werden bei der Eingabe entfernt und gemeldet.
- Zeilenüberlauf als Fehlerkasten vor „Weiter"; Mengenfeld leerbar,
  Standardmenge 100; Checkout-Checkboxen im Kartenstil.

Produktion
- skrift-agent: Poll-Modell je Maschine, Maß-Übersteuerung, Nummernfilter
  und automatischer Übergang in die Kuvertierung.

WordPress
- Neues Plugin „Skrift Preisrechner": Shortcodes inkl./exkl. MwSt,
  stündlicher Abgleich der Preisdaten in WordPress und ein sicherer
  Formel-Interpreter, damit Formeländerungen automatisch greifen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-23 13:24:12 +02:00
parent 2dbbc4611a
commit 06c4078290
62 changed files with 11384 additions and 154 deletions

View File

@@ -54,9 +54,12 @@ function buildDocuments(order, entries) {
entries.forEach((entry, i) => {
const placeholders = placeholdersOf(entry);
// Dateinummer = Briefnummer (1-basiert), damit letter_001.svg zu
// Briefnummer 1 gehört. Vorschau nummeriert identisch (siehe skrift-orders).
const nr = Number(entry.letter_number) || (i + 1);
documents.push({
index: i,
index: nr,
type: 'letter',
text: order.text_template || '',
format: letterFormat,
@@ -66,7 +69,7 @@ function buildDocuments(order, entries) {
if (wantsEnvelope) {
documents.push({
index: i,
index: nr,
type: 'envelope',
envelopeType,
text: envelopeType === 'recipient' ? addressText(entry) : (order.text_template || ''),
@@ -123,6 +126,10 @@ async function generateFromDirectus(req, res, next) {
envelopeBeschriftung: wantsEnvelope ? order.envelope_labeling : null,
directusOrderId: orderId,
customerEmail: order.customer_email,
// Vorschau-Session → gezeigte Dokumente 1:1 übernehmen.
previewSession: order.preview_session || null,
// Eingegebener Brieftext → als brieftext.txt im Ausgabeordner ablegen.
rawText: order.text_template || null,
},
});

View File

@@ -261,26 +261,43 @@ async function runGeneration({ orderNumber, letters, metadata }) {
// Step 4: Generate SVG files - separate letters and envelopes
const generatedLetters = [];
const generatedEnvelopes = [];
// Vorschau-Session (falls vorhanden): bereits gezeigte Dokumente werden 1:1
// aus dem Vorschau-Cache übernommen, damit der Kunde exakt das gedruckte
// Ergebnis erhält, das er gesehen hat (gleiche Zufalls-Handschrift).
const previewSession = (metadata || {}).previewSession;
const previewsRoot = config.paths.previews;
for (let i = 0; i < processedLetters.length; i++) {
const letter = processedLetters[i];
// Verwende loopIndex für scriptalizedMap Zuordnung
const absoluteIndex = letter.index !== undefined ? letter.index : i;
const isEnv = letter.type === 'envelope';
const filename = `${isEnv ? 'envelope' : 'letter'}_${String(absoluteIndex).padStart(3, '0')}.svg`;
const filepath = isEnv ? path.join(envelopesDir, filename) : path.join(outputDir, filename);
// 1:1 aus der Vorschau übernehmen, wenn für dieses Dokument eine
// Vorschau-Datei existiert.
if (previewSession) {
const cached = path.join(previewsRoot, previewSession, filename);
try {
await fs.access(cached);
await fs.copyFile(cached, filepath);
(isEnv ? generatedEnvelopes : generatedLetters).push(filename);
console.log(`[Order] ${filename} 1:1 aus der Vorschau übernommen.`);
continue;
} catch { /* keine Vorschau-Datei → normal rendern */ }
}
const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText;
let svgResult;
try {
if (letter.type === 'envelope') {
if (isEnv) {
svgResult = generateEnvelopeSVG(
scriptalizedText,
letter.format,
letter.envelopeType,
scriptalizedText, letter.format, letter.envelopeType,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false }
);
} else {
svgResult = generateLetterSVG(
scriptalizedText,
letter.format,
scriptalizedText, letter.format,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {} }
);
}
@@ -292,20 +309,8 @@ async function runGeneration({ orderNumber, letters, metadata }) {
throw e;
}
// Save SVG file - unterscheide zwischen Briefen und Umschlägen
const absoluteIndex = letter.index !== undefined ? letter.index : i;
if (letter.type === 'envelope') {
const filename = `envelope_${String(absoluteIndex).padStart(3, '0')}.svg`;
const filepath = path.join(envelopesDir, filename);
await fs.writeFile(filepath, svgResult.svg, 'utf8');
generatedEnvelopes.push(filename);
} else {
const filename = `letter_${String(absoluteIndex).padStart(3, '0')}.svg`;
const filepath = path.join(outputDir, filename);
await fs.writeFile(filepath, svgResult.svg, 'utf8');
generatedLetters.push(filename);
}
await fs.writeFile(filepath, svgResult.svg, 'utf8');
(isEnv ? generatedEnvelopes : generatedLetters).push(filename);
}
console.log(`[Order] Generated ${generatedLetters.length} letters, ${generatedEnvelopes.length} envelopes`);
@@ -367,6 +372,12 @@ async function runGeneration({ orderNumber, letters, metadata }) {
const orderMetadataPath = path.join(outputDir, 'order-metadata.json');
await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8');
// Eingegebenen Text als reine Textdatei mitablegen (falls übergeben).
if (meta.rawText) {
try { await fs.writeFile(path.join(outputDir, 'brieftext.txt'), String(meta.rawText), 'utf8'); }
catch (e) { console.warn('[Order] brieftext.txt nicht geschrieben:', e.message); }
}
console.log(`[Order] Order ${orderNumber} generated successfully`);
return {