Formate zentral aus Directus + Kuvert-/Agent-/Login-Fixes

Formate zentralisiert (Phase 1)
- Neue Collection render_formats (Seitengröße, Ränder, Zeilenhöhe/-limit +
  Plotter-Template) als einzige Quelle. Backend liest sie gecacht (Start +
  stündlich, synchroner Zugriff → keine Latenz) mit eingebauten Defaults als
  Fallback; manueller Trigger via POST /api/formats/refresh.
- Ermöglicht frei definierbare Formate ohne Codeänderung.

Kuvert-Templates
- Werte 1:1 aus dem alten Job-Manager übernommen (falsche/vertauschte Maße
  korrigiert, per Upsert) und je Format die zweite Variante ergänzt
  (individuelle Beschriftung). Druck-Popup schlägt bei freiem Text Variante 2 vor.

Produktions-Agent
- Datei-Endpunkt liefert {files} ohne data-Hülle → eigener get_raw statt des
  data-auspackenden get; Dokumentauswahl positionsbasiert (robust gegen 0-/1-
  basierte Dateinamen).

Bootstrap
- Ungültiger DIRECTUS_TOKEN wird erkannt und fällt auf ADMIN_EMAIL/PASSWORD
  zurück, statt mit einem 401 abzubrechen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-24 09:05:09 +02:00
parent 445ebdec5a
commit f77ddb6a4c
7 changed files with 181 additions and 76 deletions

View File

@@ -56,10 +56,27 @@ async function api(method, path, body) {
return data?.data ?? data; return data?.data ?? data;
} }
/** Prüft, ob der gesetzte Token wirklich gültig ist. */
async function tokenGueltig() {
try {
const res = await fetch(DIRECTUS_URL + '/users/me?fields=id', {
headers: { Authorization: `Bearer ${token}` },
});
return res.ok;
} catch { return false; }
}
async function login() { async function login() {
if (token) return; // Ein mitgegebener DIRECTUS_TOKEN (z. B. versehentlich aus der .env
// übernommen) wird geprüft – sonst schlägt erst der nächste Aufruf mit einem
// wenig hilfreichen 401 fehl.
if (token) {
if (await tokenGueltig()) return;
console.warn(' DIRECTUS_TOKEN ist ungültig – versuche Login mit ADMIN_EMAIL/ADMIN_PASSWORD.');
token = '';
}
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
console.error('Weder DIRECTUS_TOKEN noch ADMIN_EMAIL/ADMIN_PASSWORD gesetzt.'); console.error('DIRECTUS_TOKEN ungültig bzw. nicht gesetzt und ADMIN_EMAIL/ADMIN_PASSWORD fehlen.');
process.exit(1); process.exit(1);
} }
const res = await fetch(DIRECTUS_URL + '/auth/login', { const res = await fetch(DIRECTUS_URL + '/auth/login', {
@@ -349,6 +366,25 @@ const collections = [
f('rotation', 'integer', { interface: 'input', default: 0 }), f('rotation', 'integer', { interface: 'input', default: 0 }),
] }, ] },
// Zentrale Rendering-Formate: Seitengröße, Ränder, Zeilenhöhe/-limit. Das
// Backend liest diese (gecacht) statt hartkodierter Werte – so lassen sich
// eigene Formate anlegen, ohne Code zu ändern (eine Quelle für App + Backend).
{ collection: 'render_formats', meta: { icon: 'crop_free', note: 'Rendering-Formate (Seitengröße, Ränder, Zeilenlimit) – zentral fürs Backend.' }, fields: [
f('key', 'string', { interface: 'input', unique: true, required: true, note: 'Technischer Schlüssel, z. B. a4, a6p, din_lang, oder ein eigener.' }),
f('name', 'string', { interface: 'input', required: true }),
dropdown('kind', ['brief', 'umschlag'], { default: 'brief' }),
f('page_width_mm', 'float', { interface: 'input', required: true, note: 'Seitenbreite in mm.' }),
f('page_height_mm', 'float', { interface: 'input', required: true, note: 'Seitenhöhe in mm.' }),
f('margin_top_mm', 'float', { interface: 'input', default: 25 }),
f('margin_left_mm', 'float', { interface: 'input', default: 20 }),
f('margin_right_mm', 'float', { interface: 'input', note: 'Optional (v. a. Querformat).' }),
f('line_height_factor', 'float', { interface: 'input', default: 1.35, note: 'Zeilenhöhe = Schriftgröße × Faktor.' }),
f('line_limit', 'integer', { interface: 'input', note: 'Max. Zeilen. Leer = automatisch aus Höhe.' }),
// Zugehöriges Plotter-Druck-Template (Platzierung an der Maschine).
m2o('print_template', 'format_templates'),
f('active', 'boolean', { interface: 'boolean', default: true }),
] },
{ collection: 'jobs', meta: { icon: 'print', note: 'Produktions-Job (Druck-Warteschlange je Maschine).' }, fields: [ { collection: 'jobs', meta: { icon: 'print', note: 'Produktions-Job (Druck-Warteschlange je Maschine).' }, fields: [
m2o('order', 'orders', { onDelete: 'CASCADE' }), m2o('order', 'orders', { onDelete: 'CASCADE' }),
f('output_ref', 'string', { interface: 'input', note: 'Ausgabe-Ordner ohne Auftrag (z. B. Testdruck).' }), f('output_ref', 'string', { interface: 'input', note: 'Ausgabe-Ordner ohne Auftrag (z. B. Testdruck).' }),
@@ -664,15 +700,38 @@ async function applySeed() {
]; ];
for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t }); for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t });
// Druck-Templates (aus der bisherigen config.json der Produktions-App). // Druck-Templates – Werte 1:1 aus der config.json des alten Job-Managers.
// Je Format gibt es zwei Varianten (v1/v2); die zweite ist die zusätzliche
// Platzierung (u. a. für individuelle Kuvert-Beschriftung). Werte werden per
// Upsert gesetzt, damit falsche Altwerte beim Bootstrap korrigiert werden.
const formatTemplates = [ const formatTemplates = [
{ key: 'a4_single', name: 'A4 Brief – Einzeln', type: 'brief', width: 8.27, height: 11.69, xpos: 0.0, ypos: -0.3, scale: 1.0, rotation: 0 }, { key: 'a4_single', name: 'A4 Brief', type: 'brief', width: 8.27, height: 11.69, xpos: 0.0, ypos: -0.3, scale: 1.0, rotation: 0 },
{ key: 'a6_v1', name: 'A6 Brief – Hochformat', type: 'brief', width: 4.1, height: 5.8, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 }, { key: 'a6_v1', name: 'A6 Hochformat – Variante 1', type: 'brief', width: 4.1, height: 5.8, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 },
{ key: 'a6_v2', name: 'A6 Brief – Querformat', type: 'brief', width: 4.1, height: 5.8, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 }, { key: 'a6_v2', name: 'A6 Hochformat – Variante 2', type: 'brief', width: 4.1, height: 5.8, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 },
{ key: 'kuvert_a_v1', name: 'Kuvert DIN Lang', type: 'umschlag', width: 4.49, height: 6.38, xpos: 4.2, ypos: -0.2, scale: 1.0, rotation: 90 }, { key: 'a6l_v1', name: 'A6 Querformat – Variante 1', type: 'brief', width: 5.8, height: 4.1, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 },
{ key: 'kuvert_b_v1', name: 'Kuvert C6', type: 'umschlag', width: 3.9, height: 8.27, xpos: 4.49, ypos: -0.2, scale: 1.0, rotation: 90 }, { key: 'a6l_v2', name: 'A6 Querformat – Variante 2', type: 'brief', width: 5.8, height: 4.1, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 },
// DIN Lang – v1 = Empfängeradresse, v2 = individuelle Beschriftung.
{ key: 'kuvert_a_v1', name: 'Kuvert DIN Lang – Variante 1', type: 'umschlag', width: 4.33, height: 8.27, xpos: 4.0, ypos: -0.2, scale: 1.0, rotation: 90 },
{ key: 'kuvert_a_v2', name: 'Kuvert DIN Lang – Variante 2', type: 'umschlag', width: 3.9, height: 8.27, xpos: 4.49, ypos: -0.2, scale: 1.0, rotation: 90 },
// C6 – v1 = Empfängeradresse, v2 = individuelle Beschriftung.
{ key: 'kuvert_b_v1', name: 'Kuvert C6 – Variante 1', type: 'umschlag', width: 4.49, height: 6.38, xpos: 4.2, ypos: -0.2, scale: 1.0, rotation: 90 },
{ key: 'kuvert_b_v2', name: 'Kuvert C6 – Variante 2', type: 'umschlag', width: 4.33, height: 6.38, xpos: 4.0, ypos: -0.2, scale: 1.0, rotation: 90 },
]; ];
for (const t of formatTemplates) await seedItem('format_templates', 'key', t); for (const t of formatTemplates) {
const vorhanden = await api('GET', `/items/format_templates?filter[key][_eq]=${encodeURIComponent(t.key)}&limit=1&fields=id`);
if (vorhanden && vorhanden.length) await api('PATCH', `/items/format_templates/${vorhanden[0].id}`, t);
else { await api('POST', '/items/format_templates', t); log('seed + format_templates.' + t.key); }
}
// Rendering-Formate (zentral fürs Backend). Werte = bisherige Defaults.
const renderFormats = [
{ key: 'a4', name: 'A4', kind: 'brief', page_width_mm: 210, page_height_mm: 297, margin_top_mm: 25, margin_left_mm: 20, line_height_factor: 1.35, line_limit: 27, active: true },
{ key: 'a6p', name: 'A6 Hochformat', kind: 'brief', page_width_mm: 105, page_height_mm: 148, margin_top_mm: 8, margin_left_mm: 8, line_height_factor: 1.3, line_limit: 14, active: true },
{ key: 'a6l', name: 'A6 Querformat', kind: 'brief', page_width_mm: 148, page_height_mm: 105, margin_top_mm: 8, margin_left_mm: 8, margin_right_mm: 14, line_height_factor: 1.2, line_limit: 11, active: true },
{ key: 'din_lang', name: 'DIN Lang Kuvert', kind: 'umschlag', page_width_mm: 220, page_height_mm: 110, margin_top_mm: 50, margin_left_mm: 10, line_height_factor: 1.2, active: true },
{ key: 'c6', name: 'C6 Kuvert', kind: 'umschlag', page_width_mm: 162, page_height_mm: 114, margin_top_mm: 60, margin_left_mm: 10, line_height_factor: 1.2, active: true },
];
for (const rf of renderFormats) await seedItem('render_formats', 'key', rf);
// email_settings (Singleton) – Signatur nur setzen, wenn leer. // email_settings (Singleton) – Signatur nur setzen, wenn leer.
const es = await api('GET', '/items/email_settings'); const es = await api('GET', '/items/email_settings');
@@ -702,7 +761,7 @@ async function applySeed() {
const BIZ = ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', const BIZ = ['products', 'formats', 'motifs', 'price_items', 'pricing_settings',
'vouchers', 'voucher_redemptions', 'orders', 'order_entries', 'order_addons', 'jobs', 'status_history', 'vouchers', 'voucher_redemptions', 'orders', 'order_entries', 'order_addons', 'jobs', 'status_history',
'offer_requests', 'signatures', 'customer_discounts', 'customer_products', 'offer_requests', 'signatures', 'customer_discounts', 'customer_products',
'email_templates', 'email_settings', 'muster_settings', 'machines', 'format_templates', 'test_prints']; 'email_templates', 'email_settings', 'muster_settings', 'machines', 'format_templates', 'render_formats', 'test_prints'];
// Kunde = Login-Konto: Aufträge hängen direkt am Nutzer. // Kunde = Login-Konto: Aufträge hängen direkt am Nutzer.
const OWN_ORDER = { customer: { _eq: '$CURRENT_USER' } }; const OWN_ORDER = { customer: { _eq: '$CURRENT_USER' } };
const OWN_VIA_ORDER = { order: { customer: { _eq: '$CURRENT_USER' } } }; const OWN_VIA_ORDER = { order: { customer: { _eq: '$CURRENT_USER' } } };
@@ -817,7 +876,7 @@ async function applyPermissions() {
const svcPol = await ensurePolicy('Service', { app: false }); const svcPol = await ensurePolicy('Service', { app: false });
await ensureAccess(svcRole.id, svcPol.id); await ensureAccess(svcRole.id, svcPol.id);
for (const col of ['orders', 'order_entries', 'order_addons', 'jobs', 'products', 'formats', for (const col of ['orders', 'order_entries', 'order_addons', 'jobs', 'products', 'formats',
'motifs', 'price_items', 'machines', 'format_templates']) 'motifs', 'price_items', 'machines', 'format_templates', 'render_formats'])
await ensurePermission(svcPol.id, col, 'read'); await ensurePermission(svcPol.id, col, 'read');
await ensurePermission(svcPol.id, 'orders', 'update', await ensurePermission(svcPol.id, 'orders', 'update',
['status', 'artifact_path', 'payment_status', 'payment_ref', 'production_status', 'brief_gedruckt', 'kuvert_gedruckt']); ['status', 'artifact_path', 'payment_status', 'payment_ref', 'production_status', 'brief_gedruckt', 'kuvert_gedruckt']);
@@ -888,7 +947,7 @@ async function organizeNav() {
await patch(col, { group: null, hidden: false }); await patch(col, { group: null, hidden: false });
// Unter „Einstellungen" // Unter „Einstellungen"
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'offer_requests', for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'offer_requests',
'email_templates', 'email_settings', 'muster_settings', 'machines', 'format_templates', 'test_prints']) 'email_templates', 'email_settings', 'muster_settings', 'machines', 'format_templates', 'render_formats', 'test_prints'])
await patch(col, { group: FOLDER, hidden: false }); await patch(col, { group: FOLDER, hidden: false });
// Reine Detail-/Technik-Collections aus der Navigation nehmen (Signaturen und // Reine Detail-/Technik-Collections aus der Navigation nehmen (Signaturen und
// Rabatte werden direkt am Kundenkonto gepflegt, nicht als eigener Menüpunkt). // Rabatte werden direkt am Kundenkonto gepflegt, nicht als eigener Menüpunkt).

View File

@@ -630,7 +630,7 @@ const handler = (router, { services, getSchema, logger }) => {
// ── Druckauftrag einreihen (manueller Trigger aus dem Admin) ─────────────── // ── Druckauftrag einreihen (manueller Trigger aus dem Admin) ───────────────
// Legt pro Typ (Schriftstück / Umschlag) einen Job mit aufgelöstem Template // Legt pro Typ (Schriftstück / Umschlag) einen Job mit aufgelöstem Template
// für die gewählte Maschine an. Kein Statuswechsel am Auftrag (bewusst). // für die gewählte Maschine an. Kein Statuswechsel am Auftrag (bewusst).
const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6_v2' }; const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6l_v1' };
const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' }; const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
router.post('/enqueue', async (req, res) => { router.post('/enqueue', async (req, res) => {

View File

@@ -23,7 +23,9 @@ import { defineComponent, ref, reactive, computed, onMounted, h, Teleport, resol
import { useApi } from '@directus/extensions-sdk'; import { useApi } from '@directus/extensions-sdk';
// Auftragsformat → Standard-Template-Schlüssel (wie im Backend /enqueue). // Auftragsformat → Standard-Template-Schlüssel (wie im Backend /enqueue).
const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6_v2' }; const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6l_v1' };
// v1 = Empfängeradresse (Standardvorschlag). Für individuelle Beschriftung
// wählt der Operator im Popup die Variante 2 (kuvert_a_v2 / kuvert_b_v2).
const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' }; const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
const INLAND = /^(de|deutschland|germany)$/i; const INLAND = /^(de|deutschland|germany)$/i;
@@ -256,7 +258,10 @@ const Board = defineComponent({
function openDruck(order) { function openDruck(order) {
const bt = tplByKey(LETTER_TPL[order.format?.key] || 'a4_single', 'brief'); const bt = tplByKey(LETTER_TPL[order.format?.key] || 'a4_single', 'brief');
const wantsEnv = order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine'; const wantsEnv = order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine';
const et = wantsEnv ? tplByKey(ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1', 'umschlag') : null; // Bei individuellem Text die Variante 2 des Kuverts vorschlagen (v1 = Empfängeradresse).
let envKey = ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1';
if (wantsEnv && order.envelope_labeling === 'freitext') envKey = envKey.replace('_v1', '_v2');
const et = wantsEnv ? (tplByKey(envKey, 'umschlag') || tplByKey(ENVELOPE_TPL[order.envelope_format], 'umschlag')) : null;
const n = Math.max(1, order.entries_count || 1); const n = Math.max(1, order.entries_count || 1);
druck.order = order; druck.order = order;
druck.machine = aktiveMaschinen.value[0]?.id ?? null; druck.machine = aktiveMaschinen.value[0]?.id ?? null;

View File

@@ -1,61 +1,64 @@
const PX_PER_MM = 3.78; // 96dpi const PX_PER_MM = 3.78; // 96dpi
const FONT_SIZE_PX = 26; const FONT_SIZE_PX = 26;
// Maximale Zeilenanzahl pro Format (für Textvalidierung) // ── Formate: Defaults (Fallback) + datengetriebener Cache ───────────────────
const LINE_LIMITS = { // Die Werte kommen zentral aus Directus (Collection render_formats) und werden
a4: 27, // im Speicher gehalten (stündlich / manuell aufgefrischt via applyRenderFormats).
a6p: 14, // Ist Directus nicht erreichbar oder ein Format nicht hinterlegt, greifen die
a6l: 11, // eingebauten Defaults – die Generierung läuft also immer.
const DEFAULT_LINE_LIMITS = { a4: 27, a6p: 14, a6l: 11 };
const DEFAULT_FORMAT_LAYOUTS = {
a4: { marginTopMm: 25, marginLeftMm: 20, lineHeightFactor: 1.35 },
a6p: { marginTopMm: 8, marginLeftMm: 8, lineHeightFactor: 1.3 },
a6l: { marginTopMm: 8, marginLeftMm: 8, marginRightMm: 14, lineHeightFactor: 1.2 },
din_lang: { marginTopMm: 50, marginLeftMm: 10, lineHeightFactor: 1.2 },
c6: { marginTopMm: 60, marginLeftMm: 10, lineHeightFactor: 1.2 },
}; };
const DEFAULT_PAGE_FORMATS = {
// Layout Konfiguration pro Format + Orientation
const FORMAT_LAYOUTS = {
// A4 Hochformat
a4: {
marginTopMm: 25,
marginLeftMm: 20,
lineHeightFactor: 1.35,
},
// A6 Hochformat
a6p: {
marginTopMm: 8,
marginLeftMm: 8,
lineHeightFactor: 1.3,
},
// A6 Querformat (landscape)
a6l: {
marginTopMm: 8,
marginLeftMm: 8,
marginRightMm: 14,
lineHeightFactor: 1.2,
},
// DIN Lang Kuvert (Querformat)
din_lang: {
marginTopMm: 50,
marginLeftMm: 10, // Links unten in Ecke
lineHeightFactor: 1.2,
},
// C6 Kuvert (Querformat)
c6: {
marginTopMm: 60,
marginLeftMm: 10, // Links unten in Ecke
lineHeightFactor: 1.2,
},
};
// Seiten-Dimensionen
const PAGE_FORMATS = {
a4: { widthMm: 210, heightMm: 297 }, a4: { widthMm: 210, heightMm: 297 },
a6p: { widthMm: 105, heightMm: 148 }, a6p: { widthMm: 105, heightMm: 148 },
a6l: { widthMm: 148, heightMm: 105 }, // Querformat: getauscht a6l: { widthMm: 148, heightMm: 105 },
din_lang: { widthMm: 220, heightMm: 110 }, din_lang: { widthMm: 220, heightMm: 110 },
c6: { widthMm: 162, heightMm: 114 }, c6: { widthMm: 162, heightMm: 114 },
}; };
// Live-Cache (wird von applyRenderFormats ersetzt). Start = Defaults.
let LINE_LIMITS = { ...DEFAULT_LINE_LIMITS };
let FORMAT_LAYOUTS = { ...DEFAULT_FORMAT_LAYOUTS };
let PAGE_FORMATS = { ...DEFAULT_PAGE_FORMATS };
/**
* Übernimmt Format-Definitionen aus Directus (render_formats) in den Cache.
* Baut immer auf den Defaults auf, damit Teilangaben nichts kaputt machen.
* rows: [{ key, page_width_mm, page_height_mm, margin_top_mm, margin_left_mm,
* margin_right_mm, line_height_factor, line_limit }]
*/
function applyRenderFormats(rows) {
const pf = { ...DEFAULT_PAGE_FORMATS };
const fl = { ...DEFAULT_FORMAT_LAYOUTS };
const ll = { ...DEFAULT_LINE_LIMITS };
for (const r of Array.isArray(rows) ? rows : []) {
const k = String(r.key || '').toLowerCase();
if (!k) continue;
if (r.page_width_mm && r.page_height_mm) {
pf[k] = { widthMm: Number(r.page_width_mm), heightMm: Number(r.page_height_mm) };
}
const alt = fl[k] || {};
const layout = {
marginTopMm: Number(r.margin_top_mm ?? alt.marginTopMm ?? 25),
marginLeftMm: Number(r.margin_left_mm ?? alt.marginLeftMm ?? 20),
lineHeightFactor: Number(r.line_height_factor ?? alt.lineHeightFactor ?? 1.35),
};
const mr = r.margin_right_mm ?? alt.marginRightMm;
if (mr != null && mr !== '') layout.marginRightMm = Number(mr);
fl[k] = layout;
if (r.line_limit != null && r.line_limit !== '') ll[k] = Number(r.line_limit);
}
PAGE_FORMATS = pf;
FORMAT_LAYOUTS = fl;
LINE_LIMITS = ll;
}
// mm in Pixel umrechnen // mm in Pixel umrechnen
function mmToPx(mm) { function mmToPx(mm) {
return mm * PX_PER_MM; return mm * PX_PER_MM;
@@ -113,12 +116,10 @@ function getLineLimit(format) {
module.exports = { module.exports = {
PX_PER_MM, PX_PER_MM,
FONT_SIZE_PX, FONT_SIZE_PX,
FORMAT_LAYOUTS,
PAGE_FORMATS,
LINE_LIMITS,
mmToPx, mmToPx,
getLayoutForFormat, getLayoutForFormat,
getPageDimensions, getPageDimensions,
getLineLimit, getLineLimit,
escapeXml escapeXml,
applyRenderFormats,
}; };

View File

@@ -9,6 +9,21 @@ const healthRoutes = require('./api/routes/health-routes');
const errorHandler = require('./api/middleware/error-handler'); const errorHandler = require('./api/middleware/error-handler');
const requestLogger = require('./api/middleware/request-logger'); const requestLogger = require('./api/middleware/request-logger');
const { applyRenderFormats } = 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).
async function refreshRenderFormats() {
try {
const rows = await fetchRenderFormats();
applyRenderFormats(rows);
console.log(`[Formate] ${Array.isArray(rows) ? rows.length : 0} Format(e) aus Directus geladen.`);
} catch (e) {
console.warn(`[Formate] Konnte Directus-Formate nicht laden (nutze Defaults): ${e.message}`);
}
}
const app = express(); const app = express();
// Middleware // Middleware
@@ -22,6 +37,16 @@ app.use('/health', healthRoutes);
app.use('/api/preview', previewRoutes); app.use('/api/preview', previewRoutes);
app.use('/api/order', orderRoutes); app.use('/api/order', orderRoutes);
// Format-Cache manuell auffrischen (Token-geschützt) – für „sofort übernehmen".
app.post('/api/formats/refresh', async (req, res) => {
const token = config.auth && config.auth.apiToken;
if (token && req.headers['x-api-token'] !== token) {
return res.status(401).json({ error: 'API token required' });
}
await refreshRenderFormats();
res.json({ ok: true });
});
// Error handling // Error handling
app.use(errorHandler); app.use(errorHandler);
@@ -39,6 +64,9 @@ app.listen(config.port, () => {
console.log('════════════════════════════════════════════════════════════'); console.log('════════════════════════════════════════════════════════════');
console.log(`Server running at http://localhost:${config.port}`); console.log(`Server running at http://localhost:${config.port}`);
console.log('════════════════════════════════════════════════════════════\n'); console.log('════════════════════════════════════════════════════════════\n');
// Formate initial laden und stündlich auffrischen (zentral aus Directus).
refreshRenderFormats();
setInterval(refreshRenderFormats, 60 * 60 * 1000);
}); });
// Graceful shutdown // Graceful shutdown

View File

@@ -77,4 +77,12 @@ async function addStatusHistory(orderId, status, note) {
}); });
} }
module.exports = { fetchOrder, updateOrder, addStatusHistory }; /** Zentrale Format-Definitionen (Seitengröße, Ränder, Zeilenlimit) aus Directus. */
async function fetchRenderFormats() {
return directusFetch(
'/items/render_formats?filter[active][_eq]=true&limit=-1' +
'&fields=key,page_width_mm,page_height_mm,margin_top_mm,margin_left_mm,margin_right_mm,line_height_factor,line_limit',
);
}
module.exports = { fetchOrder, updateOrder, addStatusHistory, fetchRenderFormats };

View File

@@ -46,10 +46,17 @@ class Directus:
self.s.headers["Authorization"] = f"Bearer {token}" self.s.headers["Authorization"] = f"Bearer {token}"
def get(self, path, **params): def get(self, path, **params):
"""Für /items/… – Directus verpackt die Nutzdaten in `data`."""
r = self.s.get(self.base + path, params=params, timeout=30) r = self.s.get(self.base + path, params=params, timeout=30)
r.raise_for_status() r.raise_for_status()
return r.json().get("data") return r.json().get("data")
def get_raw(self, path, **params):
"""Für Custom-Endpunkte (z. B. /skrift-orders/files) ohne `data`-Hülle."""
r = self.s.get(self.base + path, params=params, timeout=30)
r.raise_for_status()
return r.json()
def patch(self, path, body): def patch(self, path, body):
r = self.s.patch(self.base + path, json=body, timeout=30) r = self.s.patch(self.base + path, json=body, timeout=30)
r.raise_for_status() r.raise_for_status()
@@ -152,12 +159,6 @@ def parse_nummern(spec):
return treffer or None return treffer or None
def datei_nummer(name):
"""Zahl aus einem Dateinamen ziehen (letter_003.svg → 3)."""
ziffern = "".join(ch for ch in os.path.splitext(os.path.basename(name))[0] if ch.isdigit())
return int(ziffern) if ziffern else None
def main(): def main():
cfg = lade_config() cfg = lade_config()
dx = Directus(cfg["directus_url"], cfg["directus_token"]) dx = Directus(cfg["directus_url"], cfg["directus_token"])
@@ -248,15 +249,18 @@ def _druck(dx, plotter, job, KIND):
try: try:
if not order_nr: if not order_nr:
raise RuntimeError("Auftragsnummer fehlt am Job.") raise RuntimeError("Auftragsnummer fehlt am Job.")
liste = dx.get(f"/skrift-orders/files/{order_nr}") or {} liste = dx.get_raw(f"/skrift-orders/files/{order_nr}") or {}
gewuenscht = KIND.get(typ, "schriftstueck") gewuenscht = KIND.get(typ, "schriftstueck")
passende = sorted( passende = sorted(
[f for f in (liste.get("files") or []) if f.get("kind") == gewuenscht], [f for f in (liste.get("files") or []) if f.get("kind") == gewuenscht],
key=lambda f: f.get("name", ""), key=lambda f: f.get("name", ""),
) )
# Nur bestimmte Briefnummern drucken, falls im Job angegeben. # Nur bestimmte Briefnummern drucken, falls im Job angegeben.
# Positionsbasiert (1 = erstes Dokument), NICHT über die Zahl im
# Dateinamen: Alt-Aufträge sind 0-basiert (letter_000.svg), neue
# 1-basiert (letter_001.svg). Die Position stimmt in beiden Fällen.
if nummern is not None: if nummern is not None:
passende = [f for f in passende if datei_nummer(f.get("name")) in nummern] passende = [f for i, f in enumerate(passende, start=1) if i in nummern]
if not passende: if not passende:
raise RuntimeError(f"Keine Dateien vom Typ '{gewuenscht}' für {order_nr} gefunden.") raise RuntimeError(f"Keine Dateien vom Typ '{gewuenscht}' für {order_nr} gefunden.")