Files
skrift-backend/src/api/controllers/order-controller.js
s4luorth 8030d3d0ec
All checks were successful
Build & Deploy / deploy (push) Successful in 13s
Postkarten-Layout: Text links, Absender+Empfänger rechts, Briefmarkenfeld frei
Neues render_format-kind 'postkarte'. generatePostcardSVG setzt den Text auf die
linke Kartenhälfte, Absender (klein, oben) und Empfänger (Anschriftzone unten) auf
die rechte Hälfte; oben rechts bleibt das Briefmarkenfeld frei. Ein SVG je Karte
(ein Plotter-Durchlauf), geschrieben als letter_NNN.svg.

- page-layout: kind je Format mitführen + getFormatKind().
- svg-generator: generatePostcardSVG (Body/Absender/Empfänger über Marker
  [[pk_abs]]/[[pk_adr]] getrennt, damit alle Teile scriptalisiert werden).
- order-/preview-/directus-controller: type 'postcard' dispatchen, Marker in der
  Scriptalisierung erhalten, Postkarten-Dokument aus render_format-kind bauen.
- directus-service: kind aus render_formats mitladen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-11 13:58:06 +02:00

609 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const config = require('../../config');
const { scriptalizeBatch } = require('../../services/scriptalizer-service');
const { generateLetterSVG, generateEnvelopeSVG, generatePostcardSVG, generateLetterPages } = require('../../lib/svg-generator');
const { replacePlaceholders, generateAllCSVs } = require('../../services/placeholder-service');
const { TAB_MARKER } = require('../../lib/svg-font-engine');
/**
* POST /api/order/finalize
* Finalize order by copying cached previews to output directory
*/
async function finalizeOrder(req, res, next) {
try {
const { sessionId, orderNumber } = req.body;
// Validation
if (!sessionId || !orderNumber) {
return res.status(400).json({
error: 'Invalid request',
message: 'sessionId and orderNumber are required'
});
}
console.log(`[Order] Finalizing order: ${orderNumber} from session: ${sessionId}`);
// Check if session cache exists
const sessionDir = path.join(config.paths.previews, sessionId);
try {
await fs.access(sessionDir);
} catch {
return res.status(404).json({
error: 'Session not found',
message: `Preview cache not found for session: ${sessionId}. Please generate previews first.`
});
}
// Check cache expiration
const metadataPath = path.join(sessionDir, '.metadata.json');
try {
const metadataContent = await fs.readFile(metadataPath, 'utf8');
const metadata = JSON.parse(metadataContent);
const expiresAt = new Date(metadata.expiresAt);
if (new Date() > expiresAt) {
return res.status(410).json({
error: 'Cache expired',
message: 'Preview cache has expired. Please regenerate previews.',
expiredAt: metadata.expiresAt
});
}
} catch (err) {
console.warn(`[Order] Metadata not found for session: ${sessionId}`);
}
// Create output directory and subdirectories
const outputDir = path.join(config.paths.output, orderNumber);
const envelopesDir = path.join(outputDir, 'umschlaege');
await fs.mkdir(outputDir, { recursive: true });
await fs.mkdir(envelopesDir, { recursive: true });
// Copy files from cache to output, separating letters and envelopes
const files = await fs.readdir(sessionDir);
const copiedLetters = [];
const copiedEnvelopes = [];
const copiedOther = [];
for (const file of files) {
// Skip metadata file
if (file === '.metadata.json') {
continue;
}
const sourcePath = path.join(sessionDir, file);
// Determine destination based on file type
if (file.startsWith('envelope_')) {
// Umschläge in Unterordner 'umschlaege'
const destPath = path.join(envelopesDir, file);
await fs.copyFile(sourcePath, destPath);
copiedEnvelopes.push(file);
} else if (file.startsWith('letter_')) {
// Briefe im Hauptordner
const destPath = path.join(outputDir, file);
await fs.copyFile(sourcePath, destPath);
copiedLetters.push(file);
} else {
// Andere Dateien (CSV, etc.) im Hauptordner
const destPath = path.join(outputDir, file);
await fs.copyFile(sourcePath, destPath);
copiedOther.push(file);
}
}
// Create order metadata
const meta = req.body.metadata || {};
const orderMetadata = {
orderNumber,
sessionId,
finalizedAt: new Date().toISOString(),
letterCount: copiedLetters.length,
envelopeCount: copiedEnvelopes.length,
letters: copiedLetters,
envelopes: copiedEnvelopes,
other: copiedOther,
letterFormat: meta.letterFormat || null,
envelopeFormat: meta.envelopeFormat || null,
envelopeBeschriftung: meta.envelopeBeschriftung || null,
};
const orderMetadataPath = path.join(outputDir, 'order-metadata.json');
await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8');
const totalFiles = copiedLetters.length + copiedEnvelopes.length + copiedOther.length;
console.log(`[Order] Finalized order ${orderNumber}: ${copiedLetters.length} letters, ${copiedEnvelopes.length} envelopes, ${copiedOther.length} other files`);
// Response
res.status(200).json({
orderNumber,
outputPath: outputDir,
letters: copiedLetters,
envelopes: copiedEnvelopes,
other: copiedOther,
totalFiles,
envelopesGenerated: 0,
timestamp: orderMetadata.finalizedAt
});
} catch (err) {
console.error('[Order] Error finalizing order:', err);
next(err);
}
}
/**
* POST /api/order/generate
* Generate order from scratch without using cache
*/
async function runGeneration({ orderNumber, letters, metadata }) {
{
// Validation
if (!orderNumber) {
const e = new Error('orderNumber is required');
e.status = 400;
throw e;
}
if (!letters || !Array.isArray(letters) || letters.length === 0) {
const e = new Error('Letters array is required and must not be empty');
e.status = 400;
throw e;
}
console.log(`[Order] Generating order: ${orderNumber}`);
console.log(`[Order] Total documents: ${letters.length}`);
// Debug: Zeige welche Dokument-Typen ankommen
const letterCount = letters.filter(l => l.type !== 'envelope').length;
const envelopeCount = letters.filter(l => l.type === 'envelope').length;
console.log(`[Order] Letters: ${letterCount}, Envelopes: ${envelopeCount}`);
console.log(`[Order] Document types:`, letters.map(l => ({ index: l.index, type: l.type, envelopeType: l.envelopeType })));
// Create output directory and subdirectories
const outputDir = path.join(config.paths.output, orderNumber);
const envelopesDir = path.join(outputDir, 'umschlaege');
await fs.mkdir(outputDir, { recursive: true });
await fs.mkdir(envelopesDir, { recursive: true });
// Step 1: Prepare texts and replace placeholders
const processedLetters = letters.map((letter, loopIndex) => {
let text = letter.text || '';
// Signatur-Platzhalter separat extrahieren – sie werden NICHT als Text ersetzt
const sigPlaceholders = {};
const nonSigPlaceholders = {};
if (letter.placeholders && typeof letter.placeholders === 'object') {
Object.entries(letter.placeholders).forEach(([k, v]) => {
if (/^signatur\d+$/i.test(k)) sigPlaceholders[k.toLowerCase()] = v;
else nonSigPlaceholders[k] = v;
});
text = replacePlaceholders(text, nonSigPlaceholders);
}
return {
...letter,
index: letter.index !== undefined ? letter.index : loopIndex,
loopIndex,
processedText: text,
// Signaturen aus Platzhaltern UND direkt übergebene (manuelle Aufträge).
signatures: { ...(letter.signatures || {}), ...sigPlaceholders },
format: letter.format || 'a4',
font: letter.font || 'tilda',
type: letter.type || 'letter',
envelopeType: letter.envelopeType || 'recipient',
realisticHandwriting: letter.realisticHandwriting ?? false,
};
});
// Step 2: Group letters by font for batch scriptalization
const lettersByFont = {};
processedLetters.forEach(letter => {
const font = letter.font;
if (!lettersByFont[font]) {
lettersByFont[font] = [];
}
lettersByFont[font].push(letter);
});
// Step 3: Scriptalize texts by font
const scriptalizedMap = new Map();
for (const [font, fontLetters] of Object.entries(lettersByFont)) {
// Tabs (\t) und Signatur-Marker ([[signaturN]]) vor Scriptalisierung extrahieren,
// danach mit TAB_MARKER bzw. [[signaturN]] restaurieren.
const scriptalizerInputs = [];
const splitMaps = [];
for (const letter of fontLetters) {
const raw = letter.processedText;
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|\[\[seitenumbruch\]\]|\[\[pk_abs\]\]|\[\[pk_adr\]\]|[&|])/gi);
const segments = [], markers = [];
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) segments.push(parts[i]);
else markers.push(parts[i]);
}
// Leere Segmente NICHT an den Scriptalizer senden
const startIdx = scriptalizerInputs.length;
const hasContent = segments.map(s => s.trim().length > 0);
segments.forEach((s, j) => { if (hasContent[j]) scriptalizerInputs.push(s); });
splitMaps.push({ loopIndex: letter.loopIndex, startIdx, segments, markers, hasContent, fallback: raw });
}
console.log(`[Order] Scriptalizing ${scriptalizerInputs.length} segments with font: ${font}`);
try {
const scriptalizedAll = await scriptalizeBatch(scriptalizerInputs, font, config.scriptalizer.errFrequency);
for (const { loopIndex, startIdx, segments, markers, hasContent, fallback } of splitMaps) {
const nonEmptyCount = hasContent.filter(Boolean).length;
if (!nonEmptyCount && !markers.length) { scriptalizedMap.set(loopIndex, fallback); continue; }
let si = startIdx;
const segs = segments.map((_s, j) => hasContent[j] ? (scriptalizedAll[si++] || '') : '');
let reconstructed = segs[0] || '';
for (let i = 0; i < markers.length; i++) {
reconstructed += (markers[i] === '\t' ? TAB_MARKER : markers[i]);
reconstructed += (segs[i + 1] || '');
}
scriptalizedMap.set(loopIndex, reconstructed);
}
} catch (err) {
console.error(`[Order] Scriptalization failed for font ${font}:`, err.message);
const e = new Error(`Failed to scriptalize texts with font ${font}: ${err.message}`);
e.status = 500;
e.label = 'Scriptalization failed';
throw e;
}
}
// 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];
const absoluteIndex = letter.index !== undefined ? letter.index : i;
const isEnv = letter.type === 'envelope';
const isPostcard = letter.type === 'postcard';
const pad = String(absoluteIndex).padStart(3, '0');
// Mehrseitiger Brief → N Seiten-Dateien letter_NNN_pM.svg (Seite 1..N).
if (!isEnv && letter.multiPage) {
const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText;
let pages;
try {
({ pages } = generateLetterPages(scriptalizedText, letter.format, {
font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale,
}));
} catch (err) {
const e = new Error(`Failed to generate SVG for letter ${i}: ${err.message}`);
e.status = 500; e.label = 'SVG generation failed'; throw e;
}
// Entweder ALLE Seiten aus der Vorschau übernehmen oder ALLE neu rendern –
// nicht mischen, sonst unterschiedliche Handschrift-Variation je Seite.
let alleInVorschau = false;
if (previewSession) {
alleInVorschau = true;
for (let p = 0; p < pages.length; p++) {
try { await fs.access(path.join(previewsRoot, previewSession, `letter_${pad}_p${p + 1}.svg`)); }
catch { alleInVorschau = false; break; }
}
}
for (let p = 0; p < pages.length; p++) {
const fn = `letter_${pad}_p${p + 1}.svg`;
const fp = path.join(outputDir, fn);
if (alleInVorschau) await fs.copyFile(path.join(previewsRoot, previewSession, fn), fp);
else await fs.writeFile(fp, pages[p], 'utf8');
generatedLetters.push(fn);
}
console.log(`[Order] letter_${pad}: ${pages.length} Seiten${alleInVorschau ? ' (aus Vorschau)' : ''}.`);
continue;
}
const filename = `${isEnv ? 'envelope' : 'letter'}_${pad}.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. Ausnahme: Kuvert mit Absender – das wird frisch
// gerendert, damit der Absender (oben links) sicher drauf ist.
if (previewSession && !(isEnv && metadata.envelopeSender)) {
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 (isEnv) {
svgResult = generateEnvelopeSVG(
scriptalizedText, letter.format, letter.envelopeType,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale, sender: metadata.envelopeSender || '', senderScale: metadata.envelopeSenderScale || 1 }
);
} else if (isPostcard) {
svgResult = generatePostcardSVG(
scriptalizedText, letter.format,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale, senderScale: metadata.envelopeSenderScale || 1 }
);
} else {
svgResult = generateLetterSVG(
scriptalizedText, letter.format,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {}, signatureOffsets: letter.signatureOffsets || {}, fontScale: letter.fontScale }
);
}
} catch (err) {
console.error(`[Order] SVG generation failed for letter ${i}:`, err.message);
const e = new Error(`Failed to generate SVG for letter ${i}: ${err.message}`);
e.status = 500;
e.label = 'SVG generation failed';
throw e;
}
await fs.writeFile(filepath, svgResult.svg, 'utf8');
(isEnv ? generatedEnvelopes : generatedLetters).push(filename);
}
console.log(`[Order] Generated ${generatedLetters.length} letters, ${generatedEnvelopes.length} envelopes`);
// Step 5: Generate all CSV files if placeholders are present
const hasPlaceholders = processedLetters.some(l => l.placeholders && Object.keys(l.placeholders).length > 0);
const csvFiles = [];
if (hasPlaceholders) {
try {
const csvs = generateAllCSVs(processedLetters);
// Brief-Platzhalter CSV
if (csvs.placeholders) {
const filename = 'brief_platzhalter.csv';
await fs.writeFile(path.join(outputDir, filename), csvs.placeholders, 'utf8');
csvFiles.push(filename);
console.log(`[Order] Generated brief_platzhalter.csv`);
}
// Empfänger CSV (für recipientData-Modus)
if (csvs.recipients) {
const filename = 'empfaenger.csv';
await fs.writeFile(path.join(outputDir, filename), csvs.recipients, 'utf8');
csvFiles.push(filename);
console.log(`[Order] Generated empfaenger.csv`);
}
// Umschlag-Platzhalter CSV (für customText-Modus)
if (csvs.envelopePlaceholders) {
const filename = 'umschlag_platzhalter.csv';
await fs.writeFile(path.join(outputDir, filename), csvs.envelopePlaceholders, 'utf8');
csvFiles.push(filename);
console.log(`[Order] Generated umschlag_platzhalter.csv`);
}
} catch (err) {
console.error('[Order] CSV generation failed:', err.message);
// Don't fail the request, CSV is optional
}
}
// Step 6: Create order metadata
const meta = metadata || {};
const orderMetadata = {
orderNumber,
generatedAt: new Date().toISOString(),
letterCount: generatedLetters.length,
envelopeCount: generatedEnvelopes.length,
letters: generatedLetters,
envelopes: generatedEnvelopes,
fonts: Object.keys(lettersByFont),
hasPlaceholders,
csvFiles,
letterFormat: meta.letterFormat || null,
envelopeFormat: meta.envelopeFormat || null,
envelopeBeschriftung: meta.envelopeBeschriftung || null,
};
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 {
orderNumber,
outputPath: outputDir,
letters: generatedLetters,
envelopes: generatedEnvelopes,
csvFiles,
totalFiles: generatedLetters.length + generatedEnvelopes.length + csvFiles.length,
timestamp: orderMetadata.generatedAt
};
}
}
/**
* POST /api/order/generate
* HTTP-Wrapper um runGeneration – Verhalten unverändert.
*/
async function generateOrder(req, res, next) {
try {
const { orderNumber, letters, metadata } = req.body;
const result = await runGeneration({ orderNumber, letters, metadata });
res.status(200).json(result);
} catch (err) {
if (err.status) {
return res.status(err.status).json({
error: err.label || 'Invalid request',
message: err.message
});
}
console.error('[Order] Error generating order:', err);
next(err);
}
}
/**
* POST /api/order/motif
* Upload a motif image for an order
*/
async function uploadMotif(req, res, next) {
try {
const { orderNumber } = req.body;
// Validation
if (!orderNumber) {
return res.status(400).json({
error: 'Invalid request',
message: 'orderNumber is required'
});
}
if (!req.file) {
return res.status(400).json({
error: 'Invalid request',
message: 'No file uploaded'
});
}
console.log(`[Order] Uploading motif for order: ${orderNumber}`);
console.log(`[Order] File: ${req.file.originalname}, Size: ${req.file.size} bytes, Type: ${req.file.mimetype}`);
// Create output directory if it doesn't exist
const outputDir = path.join(config.paths.output, orderNumber);
await fs.mkdir(outputDir, { recursive: true });
// Determine file extension
const originalExt = path.extname(req.file.originalname).toLowerCase() || '.png';
const filename = `motif${originalExt}`;
const filepath = path.join(outputDir, filename);
// Write file to disk
await fs.writeFile(filepath, req.file.buffer);
console.log(`[Order] Motif saved to: ${filepath}`);
// Response
res.status(200).json({
success: true,
orderNumber,
filename,
path: filepath,
size: req.file.size,
mimetype: req.file.mimetype
});
} catch (err) {
console.error('[Order] Error uploading motif:', err);
next(err);
}
}
/**
* POST /api/order/files
* Speichert eine bereits vom WordPress-Frontend hochgeladene Datei
* (Adress-/Platzhalter-Excel, Briefdokument) im Auftragsordner ab,
* damit sie zusammen mit den generierten Umschlägen vorliegt.
*/
async function uploadOrderFile(req, res, next) {
try {
const { orderNumber, fileType } = req.body;
if (!orderNumber || !fileType) {
return res.status(400).json({
error: 'Invalid request',
message: 'orderNumber and fileType are required'
});
}
if (!req.file) {
return res.status(400).json({
error: 'Invalid request',
message: 'No file uploaded'
});
}
const outputDir = path.join(config.paths.output, orderNumber, 'dateien');
await fs.mkdir(outputDir, { recursive: true });
const ext = path.extname(req.file.originalname) || '';
const filename = `${fileType}${ext}`;
const filepath = path.join(outputDir, filename);
await fs.writeFile(filepath, req.file.buffer);
console.log(`[Order] File saved for order ${orderNumber}: ${filename}`);
res.status(200).json({
success: true,
orderNumber,
fileType,
filename,
path: filepath,
size: req.file.size,
});
} catch (err) {
console.error('[Order] Error saving order file:', err);
next(err);
}
}
/**
* POST /api/order/register
* Speichert Metadaten für einen Auftrag ohne SVG-Generierung (z.B. gedruckte Briefe)
*/
async function registerOrder(req, res, next) {
try {
const { orderNumber, metadata } = req.body;
if (!orderNumber) {
return res.status(400).json({ error: 'Invalid request', message: 'orderNumber is required' });
}
const outputDir = path.join(config.paths.output, orderNumber);
await fs.mkdir(outputDir, { recursive: true });
const orderMetadata = {
orderNumber,
registeredAt: new Date().toISOString(),
...(metadata || {}),
};
await fs.writeFile(
path.join(outputDir, 'order-metadata.json'),
JSON.stringify(orderMetadata, null, 2),
'utf8'
);
console.log(`[Order] Registered order ${orderNumber}`);
res.status(200).json({ orderNumber, registeredAt: orderMetadata.registeredAt });
} catch (err) {
console.error('[Order] Error registering order:', err);
next(err);
}
}
module.exports = {
finalizeOrder,
generateOrder,
runGeneration,
uploadMotif,
uploadOrderFile,
registerOrder,
};