import { promisify } from 'node:util' import child_process from 'node:child_process' import { existsSync, mkdirSync } from 'node:fs' import { writeFile } from 'node:fs/promises' import { renderPdfmake } from '../../../utils/offers/agbPdf' /** * Mobile reconditioning — A4 "Umlagerungsbericht" (movement report). * * Renders (pdfmake, server-side so the Capacitor bundle needs no PDF library) a one-page * A4 sheet that tells the warehouse where each reconditioned article has to go and how * many, plus the articles that were disposed of instead, and sends it to a CUPS paper * queue via `lp` — same mechanics as /api/print/picklist (whitelisted queue name, * `-o sides=one-sided`). Printing is fail-soft for the caller: the movement is already * booked, so a print failure only produces a warning toast on the phone. * * Body: { * printer?: string, // CUPS queue (CUST_Printer.cupsName); '' → CUPS default * returnPdf?: boolean, // also return the PDF as base64 (preview / dev without CUPS) * report: { * returnDocumentNo, rmaDocumentNo, partnerName, orgName, userName, * movementDocumentNo?, inventoryDocumentNo?, * moveLines: [{ value, name, qty, fromCode, toCode }], * disposeLines: [{ value, name, qty, fromCode }] * } * } */ const SAFE_PRINTER = /^[A-Za-z0-9_.-]+$/ const STORAGE_DIR = '/root/storage/reconditioning' const s = (v: any) => (v === undefined || v === null) ? '' : String(v) const fmtDateTime = (d: Date) => d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) const buildDocDefinition = (r: any) => { const moveLines: any[] = Array.isArray(r?.moveLines) ? r.moveLines : [] const disposeLines: any[] = Array.isArray(r?.disposeLines) ? r.disposeLines : [] const totalMove = moveLines.reduce((sum, l) => sum + (Number(l.qty) || 0), 0) const totalDispose = disposeLines.reduce((sum, l) => sum + (Number(l.qty) || 0), 0) // Group by target locator so one locator = one block the picker can work through. const byTarget = new Map() for (const l of moveLines) { const key = s(l.toCode) || '–' if (!byTarget.has(key)) byTarget.set(key, []) byTarget.get(key)!.push(l) } const targets = [...byTarget.keys()].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) const headCell = (text: string, alignment: string = 'left') => ({ text, style: 'th', alignment }) const cell = (text: string, alignment: string = 'left', extra: any = {}) => ({ text, alignment, ...extra }) const content: any[] = [ { text: 'Reconditioning – Umlagerungsbericht', style: 'title' }, { columns: [ { width: '*', stack: [ { text: [{ text: 'Retoure: ', bold: true }, s(r?.returnDocumentNo) || '–'] }, { text: [{ text: 'RMA: ', bold: true }, s(r?.rmaDocumentNo) || '–'] }, { text: [{ text: 'Kunde: ', bold: true }, s(r?.partnerName) || '–'] }, { text: [{ text: 'Organisation: ', bold: true }, s(r?.orgName) || '–'] } ] }, { width: '*', stack: [ { text: [{ text: 'Datum: ', bold: true }, fmtDateTime(new Date())] }, { text: [{ text: 'Bearbeiter: ', bold: true }, s(r?.userName) || '–'] }, { text: [{ text: 'Umlagerung: ', bold: true }, s(r?.movementDocumentNo) || '–'] }, ...(disposeLines.length ? [{ text: [{ text: 'Inventur (Entsorgung): ', bold: true }, s(r?.inventoryDocumentNo) || '–'] }] : []) ] } ], margin: [0, 6, 0, 14] } ] if (moveLines.length) { content.push({ text: `Einlagern – ${totalMove} Stk in ${targets.length} Lagerplatz${targets.length === 1 ? '' : 'e'}`, style: 'h2' }) for (const target of targets) { const lines = byTarget.get(target)!.slice().sort((a, b) => s(a.value).localeCompare(s(b.value), undefined, { numeric: true })) const sub = lines.reduce((sum, l) => sum + (Number(l.qty) || 0), 0) content.push({ table: { headerRows: 1, widths: [22, 80, '*', 72, 40, 62], body: [ [ { text: '', style: 'th' }, { text: 'Ziel: ' + target, style: 'thTarget', colSpan: 5 }, {}, {}, {}, {} ], [headCell('OK', 'center'), headCell('Artikel-Nr.'), headCell('Bezeichnung'), headCell('Von Lagerplatz'), headCell('Menge', 'right'), headCell('Ziel', 'left')], ...lines.map((l) => [ { canvas: [{ type: 'rect', x: 4, y: 1, w: 10, h: 10, lineWidth: 0.8 }] }, cell(s(l.value) || '–', 'left', { style: 'mono' }), cell(s(l.name)), cell(s(l.fromCode) || '–', 'left', { style: 'mono' }), cell(String(Number(l.qty) || 0), 'right', { bold: true, fontSize: 12 }), cell(target, 'left', { style: 'mono', bold: true }) ]), [ { text: '', colSpan: 4 }, {}, {}, {}, cell(String(sub), 'right', { bold: true }), { text: 'Stk', italics: true } ] ] }, layout: 'lightHorizontalLines', margin: [0, 0, 0, 12] }) } } if (disposeLines.length) { const lines = disposeLines.slice().sort((a, b) => s(a.value).localeCompare(s(b.value), undefined, { numeric: true })) content.push({ text: `Entsorgen – ${totalDispose} Stk (nicht aufbereitbar, Bestand ausgebucht)`, style: 'h2', color: '#b45309' }) content.push({ table: { headerRows: 1, widths: [22, 80, '*', 72, 40, 62], body: [ [headCell('OK', 'center'), headCell('Artikel-Nr.'), headCell('Bezeichnung'), headCell('Von Lagerplatz'), headCell('Menge', 'right'), headCell('Aktion')], ...lines.map((l) => [ { canvas: [{ type: 'rect', x: 4, y: 1, w: 10, h: 10, lineWidth: 0.8 }] }, cell(s(l.value) || '–', 'left', { style: 'mono' }), cell(s(l.name)), cell(s(l.fromCode) || '–', 'left', { style: 'mono' }), cell(String(Number(l.qty) || 0), 'right', { bold: true, fontSize: 12 }), cell('ENTSORGEN', 'left', { bold: true, color: '#b45309' }) ]) ] }, layout: 'lightHorizontalLines', margin: [0, 0, 0, 12] }) } content.push({ columns: [ { width: '*', stack: [{ canvas: [{ type: 'line', x1: 0, y1: 0, x2: 200, y2: 0, lineWidth: 0.7 }] }, { text: 'Umgelagert von / Datum', fontSize: 8, color: '#666', margin: [0, 2, 0, 0] }] }, { width: '*', stack: [{ canvas: [{ type: 'line', x1: 0, y1: 0, x2: 200, y2: 0, lineWidth: 0.7 }] }, { text: 'Geprüft von / Datum', fontSize: 8, color: '#666', margin: [0, 2, 0, 0] }] } ], margin: [0, 30, 0, 0] }) return { pageSize: 'A4', pageMargins: [36, 36, 36, 40], defaultStyle: { fontSize: 10 }, styles: { title: { fontSize: 18, bold: true, margin: [0, 0, 0, 4] }, h2: { fontSize: 13, bold: true, margin: [0, 8, 0, 6] }, th: { bold: true, fillColor: '#f1f5f9', fontSize: 9 }, thTarget: { bold: true, fontSize: 12, fillColor: '#dbeafe' }, mono: { fontSize: 10 } }, footer: (currentPage: number, pageCount: number) => ({ columns: [ { text: `Reconditioning · Retoure ${s(r?.returnDocumentNo)}${s(r?.rmaDocumentNo) ? ' · RMA ' + s(r?.rmaDocumentNo) : ''}`, fontSize: 8, color: '#666' }, { text: `Seite ${currentPage} / ${pageCount}`, alignment: 'right', fontSize: 8, color: '#666' } ], margin: [36, 10, 36, 0] }), content } } export default defineEventHandler(async (event) => { const body = await readBody(event) const report = body?.report || {} const returnPdf = body?.returnPdf === true const printer = SAFE_PRINTER.test(body?.printer || '') ? String(body.printer) : '' let buffer: Buffer try { buffer = await renderPdfmake(buildDocDefinition(report)) } catch (err: any) { console.error('[reconditioning/print-report] PDF render failed:', err) setResponseStatus(event, 500) return { status: 500, message: 'PDF konnte nicht erzeugt werden: ' + (err?.message || err) } } const safeDoc = s(report?.returnDocumentNo).replace(/[^A-Za-z0-9_-]/g, '') || 'retoure' const fileName = `Reconditioning-${safeDoc}-${Date.now()}.pdf` const result: any = { status: 200, fileName, printer: printer || 'default', printed: false } if (returnPdf) result.pdfBase64 = buffer.toString('base64') if (body?.print === false) return result try { if (!existsSync(STORAGE_DIR)) mkdirSync(STORAGE_DIR, { recursive: true }) const filePath = `${STORAGE_DIR}/${fileName}` await writeFile(filePath, buffer) const exec = promisify(child_process.exec) const { stdout, stderr } = await exec(`lp ${printer ? '-d ' + printer + ' ' : ''}-o sides=one-sided -o media=A4 ${filePath}`) result.printed = !stderr result.stdout = stdout result.stderr = stderr if (stderr) { result.status = 500 result.message = 'Druck fehlgeschlagen: ' + stderr setResponseStatus(event, 500) } } catch (err: any) { console.error('[reconditioning/print-report] print failed:', err) result.status = 500 result.message = 'Druck fehlgeschlagen: ' + (err?.message || err) setResponseStatus(event, 500) } return result })