diff --git a/docker-compose.yml b/docker-compose.yml index 45fd84c..a6e05b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,7 +42,9 @@ services: - ./extensions:/directus/extensions # Generierte Auftragsdateien read-only einblenden (gleicher Ordner wie # das Backend beschreibt / SFTP-Ziel) – für Direktzugriff statt Kopie. - - /var/skrift-output:/var/skrift-output:ro + # Schreibbar: die Unterschriftenservice-Auftragsanlage schreibt die + # Seiten-SVGs direkt in den Auftragsordner (sonst nur lesend). + - /var/skrift-output:/var/skrift-output environment: SECRET: ${DIRECTUS_SECRET} diff --git a/extensions/directus-extension-skrift-neuauftrag/dist/index.js b/extensions/directus-extension-skrift-neuauftrag/dist/index.js index cac4723..3cf4225 100644 --- a/extensions/directus-extension-skrift-neuauftrag/dist/index.js +++ b/extensions/directus-extension-skrift-neuauftrag/dist/index.js @@ -58,7 +58,7 @@ const NeuAuftrag = defineComponent({ const sig = reactive({ modus: 'auftrag', // 'auftrag' (aus Auftrag laden) oder 'manuell' (direkt hochladen) orderNr: '', order: null, docs: [], docId: null, docName: '', signaturen: [], - laden: false, pdf: '', msg: '', err: '', + customerEmail: '', laden: false, pdf: '', msg: '', err: '', }); // Datei nach Directus hochladen (liefert die Datei-ID). async function sigUpload(file) { @@ -646,6 +646,18 @@ const NeuAuftrag = defineComponent({ sig.msg = 'ZIP heruntergeladen – A4-Signaturen an den Plotter geben.'; } catch (e) { sig.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Erzeugung fehlgeschlagen.'; sig.msg = ''; } } + // Legt einen echten Auftrag an: erzeugt je PDF-Seite eine A4-SVG (leer wo kein + // Marker) und schreibt sie als Schriftstück. Danach im Produktions-Board unter + // „Neu" → dort Drucken → Plotter (wie ein normaler Auftrag). + async function sigAuftrag() { + if (!sig.docId) { sig.err = 'Bitte ein Dokument wählen/hochladen.'; return; } + if (!sigAktive().length) { sig.err = 'Mindestens eine Unterschrift aktivieren.'; return; } + sig.err = ''; sig.msg = 'Auftrag wird angelegt …'; + try { + const res = await api.post('/skrift-orders/signature-order', { ...sigPayload(), customer_email: sig.customerEmail || undefined }); + sig.msg = `✔ Auftrag ${res.data.order_number} angelegt (${res.data.pages} Seite/n). Im Produktions-Board unter „Neu" → Drucken.`; + } catch (e) { sig.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Auftrag konnte nicht angelegt werden.'; sig.msg = ''; } + } function signaturPanel() { const sigFeld = (s, k, lbl, step) => h('div', { style: 'display:flex;flex-direction:column;gap:3px;flex:1;min-width:90px;' }, [ @@ -707,8 +719,14 @@ const NeuAuftrag = defineComponent({ sigFeld(s, 'offset_y_mm', 'Y mm'), ]), ])), - h('div', { class: 'sk-btnrow', style: 'display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;' }, [ + // Kunde zuordnen (für den anzulegenden Auftrag) + Aktionen. + h('div', { style: 'margin-top:12px;max-width:360px;' }, [ + label('Kundenmail (Auftrag zuordnen, optional)'), + h('input', { value: sig.customerEmail, type: 'email', placeholder: 'kunde@…', style: WFELD + 'width:100%;', onInput: (e) => { sig.customerEmail = e.target.value; } }), + ]), + h('div', { class: 'sk-btnrow', style: 'display:flex;gap:12px;flex-wrap:wrap;margin-top:12px;' }, [ btn(sig.msg ? '…' : '👁 Vorschau', sigVorschau, 'primary'), + btn('🖨 Auftrag anlegen', sigAuftrag, 'primary'), btn('⤓ A4-Signaturen (ZIP)', sigDownload), ]), ]); diff --git a/extensions/directus-extension-skrift-orders/dist/index.js b/extensions/directus-extension-skrift-orders/dist/index.js index 7cb881d..3f94ba6 100644 --- a/extensions/directus-extension-skrift-orders/dist/index.js +++ b/extensions/directus-extension-skrift-orders/dist/index.js @@ -245,6 +245,96 @@ const handler = (router, { services, getSchema, logger }) => { // A4-Signatur-SVGs als ZIP (zum Plotten in der lokalen Produktion). router.post('/signature-render', (req, res) => signatureAufruf(req, res, '/render', 'application/zip', 'zip_base64')); + // Unterschriftenservice als ECHTEN Auftrag anlegen: erzeugt je PDF-Seite eine + // A4-SVG (leer, wo kein Marker), schreibt sie als Schriftstück in den Auftrags- + // ordner und legt den Auftrag an. Danach läuft alles über die normale Produktion + // (Drucken → Plotter). KEIN neuer Job-Typ. Setzt den schreibbaren Output-Mount voraus. + router.post('/signature-order', async (req, res) => { + const acc = req.accountability; + if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' }); + const body = req.body || {}; + if (!body.order && !body.document_id) return res.status(400).json({ error: 'order oder document_id erforderlich.' }); + const sigList = Array.isArray(body.signatures) ? body.signatures : []; + if (!sigList.length) return res.status(400).json({ error: 'Mindestens eine Unterschrift wählen.' }); + try { + const schema = await getSchema(); + const svc = (c) => new ItemsService(c, { schema, accountability: null }); + + let ord = null; + if (body.order) { + ord = await svc('orders').readOne(body.order, { fields: ['id', 'source_file', 'source_files'] }); + if (!ord) return res.status(404).json({ error: 'Auftrag nicht gefunden.' }); + } + const pdfId = body.document_id || (ord ? (ord.source_file || (Array.isArray(ord.source_files) ? ord.source_files[0] : null)) : null); + if (!pdfId) return res.status(400).json({ error: 'Kein Dokument (document_id fehlt).' }); + + const prodKey = body.product || 'unterschriftenservice'; + let product = (await svc('products').readByQuery({ filter: { key: { _eq: prodKey } }, limit: 1, fields: ['id', 'key', 'name'] }))?.[0]; + if (!product) product = (await svc('products').readByQuery({ filter: { input_mode: { _eq: 'datei' } }, limit: 1, fields: ['id', 'key', 'name'] }))?.[0]; + if (!product) return res.status(400).json({ error: 'Kein Unterschriftenservice-/Datei-Produkt gefunden.' }); + + let customerId = null, customerEmail = null; + if (body.customer_email && isEmail(norm(body.customer_email))) { + const u = (await new UsersService({ schema, accountability: null }).readByQuery({ + filter: { email: { _eq: norm(body.customer_email) } }, limit: 1, fields: ['id', 'email'], + }))?.[0]; + if (u) { customerId = u.id; customerEmail = u.email; } else customerEmail = norm(body.customer_email); + } + + // A4-Signaturen erzeugen (eine je Seite) – JSON-Variante. + const { form, jobCount } = await signatureForm(schema, pdfId, sigList); + if (!jobCount) return res.status(400).json({ error: 'Keine gültige Signatur (SVG-Datei fehlt).' }); + let r; + try { r = await fetch(`${SIGN_URL}/render-json`, { method: 'POST', body: form }); } + catch (e) { return res.status(502).json({ error: 'Signatur-Service nicht erreichbar.', detail: `${SIGN_URL} – ${e.message}` }); } + if (!r.ok) { + let d = ''; try { d = (await r.json())?.detail || ''; } catch { /* ignore */ } + return res.status(502).json({ error: 'Signatur-Service-Fehler (Marker im PDF?).', detail: String(Array.isArray(d) ? d.join('; ') : d).slice(0, 400) }); + } + const pages = (await r.json())?.pages || []; + if (!pages.length) return res.status(422).json({ error: 'Keine Seiten erzeugt.' }); + + const jt = new Date(); + const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`; + const naechsteNummer = async () => { + const heutige = await svc('orders').readByQuery({ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'] }); + return `${praefix}-${String((heutige?.length ?? 0) + 1).padStart(3, '0')}`; + }; + const orderData = { + customer: customerId, customer_email: customerEmail, product: product.id, + person_type: 'privat', source: 'operator', status: 'in_queue', production_status: 'eingegangen', + payment_status: 'bezahlt', payment_method: 'rechnung', shipping_type: 'sammel', + needs_envelope: false, envelope_labeling: 'keine', + source_file: pdfId, source_files: [pdfId], + entries_count: pages.length, count_inland: pages.length, count_ausland: 0, + }; + let orderId, orderNumber; + for (let v = 0; v < 6; v += 1) { + orderNumber = await naechsteNummer(); + try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; } + catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; } + } + + const entriesSvc = svc('order_entries'); + for (let i = 0; i < pages.length; i += 1) { + await entriesSvc.createOne({ order: orderId, letter_number: i + 1, free_text: `Seite ${i + 1}` }); + } + await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Unterschriftenservice – manuell angelegt.' }).catch(() => {}); + + // Seiten-SVGs als Schriftstück in den Auftragsordner schreiben. + const ordner = path.join(OUTPUT_DIR, orderNumber); + fs.mkdirSync(ordner, { recursive: true }); + pages.forEach((p, i) => { + fs.writeFileSync(path.join(ordner, `letter_${String(i + 1).padStart(3, '0')}.svg`), p.svg, 'utf8'); + }); + + return res.json({ ok: true, order_number: orderNumber, id: orderId, pages: pages.length }); + } catch (err) { + logger.error(`[skrift-orders] signature-order: ${err.stack || err.message}`); + return res.status(500).json({ error: 'Auftrag konnte nicht angelegt werden.', detail: String(err.message || err).slice(0, 400) }); + } + }); + // Policy-konformes Zufallspasswort (Kunde meldet sich ohnehin passwortlos an). const zufallsPasswort = () => `Aa1!${crypto.randomBytes(24).toString('base64url').replace(/[^A-Za-z0-9]/g, '')}`;