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 { jsPDF } from 'jspdf' import fetchHelper from '../../utils/fetchHelper' import getTokenHelper from '../../utils/getTokenHelper' import { drawCode128, fitTextLine } from '../../utils/labelBarcode' import { resolveLabelMedia, type ResolvedMedia } from '../../utils/labelMedia' import { mediaOption } from '../../utils/cupsMedia' // --------------------------------------------------------------------------- // Return label — POST /api/print/return-label // // ONE label per return (not one per article/qty): the label identifies the return // document, the barcode encodes its number (RMA DocumentNo for an RMA return — // what /mobile/reconditioning scans — or the customer-return document number), // and every returned article is listed with its quantity. When the lines don't // fit, continuation labels ("Seite 2/2") of the same format follow. // // Body: { organizationId, kind: 'rma' | 'customer-return', documentNo, // partnerName?, reason?, locatorCode?, orgName?, // lines: [{ name, value?, sku?, qty, locatorCode? }], // labelPrinterId?: 'a'..'f' (m_product.labelprinter_rma), downloadOnly?, // mediaOverride?: { w, h } (downloadOnly previews only) } // // Page size = the printer's real label size (resolveLabelMedia: app registry → // CUPS queue default → legacy per-printer fallback) and the layout adapts: // tiny h < 24 mm (35×17) doc no + barcode + company · totals // compact h < 40 mm (54×29) company/date · doc no · barcode · first lines // standard h ≥ 40 mm (74×52, 56×75, 102×152) header band, doc no, barcode, // partner/reason, article table, footer // Printing = same mechanics as /api/print/product (lp, fit-to-page, explicit // media only for registry sizes). Company name = ad_org.companyname (fetched // server-side by organizationId; falls back to body.orgName). // --------------------------------------------------------------------------- const LABEL_PRINTERS: Record = { a: 'labelprinter-1', b: 'labelprinter-2', c: 'labelprinter-3', d: 'labelprinter-4', e: 'labelprinter-5', f: 'labelprinter-6' } const DEFAULT_QUEUE = 'labelprinter-2' // Legacy formats (mm) when no media can be resolved (dev without CUPS) — the // 2026-09 prod PPD defaults. const LEGACY_FORMAT: Record = { a: [102, 152], b: [35, 17], c: [56, 75], d: [102, 152], e: [102, 152], f: [54, 29] } const SAFE_QUEUE = /^[A-Za-z0-9_.-]+$/ const PT_MM = 0.3528 const s = (v: any) => (v === undefined || v === null) ? '' : String(v).replace(/\s+/g, ' ').trim() const berlinNow = () => new Date().toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin' }).replace(',', '') interface LabelLine { name: string; value: string; qty: number; locatorCode: string } interface LabelData { kind: 'rma' | 'customer-return' documentNo: string title: string companyName: string partnerName: string reason: string locatorCode: string date: string lines: LabelLine[] totalQty: number } /** Fit a single text line into maxW (shrinks font to minFs, then ellipsises). */ const putFit = (doc: any, text: string, x: number, y: number, maxW: number, maxFs: number, minFs: number, opts: any = {}) => { const { text: str, fontSize } = fitTextLine(doc, text, maxW, maxFs, minFs) doc.setFontSize(fontSize) doc.text(str, x, y, { baseline: 'top', ...opts }) return fontSize * PT_MM } const lineLabel = (l: LabelLine) => [l.value, l.name].filter(Boolean).join(' ') // ---- layouts --------------------------------------------------------------- const drawTiny = (doc: any, W: number, H: number, d: LabelData) => { const m = 1.5 let y = m doc.setFont('helvetica', 'bold') y += putFit(doc, `${d.title} ${d.documentNo}`, m, y, W - 2 * m, 9, 6) + 0.6 const bottomH = 2.4 const bcH = Math.max(5, H - y - bottomH - m - 0.8) drawCode128(doc, d.documentNo, m, y, W - 2 * m, bcH, { quietMm: 1.5, maxModuleMm: 0.5 }) y += bcH + 0.6 doc.setFont('helvetica', 'normal') doc.setFontSize(5) const totals = `${d.lines.length} Pos. · ${d.totalQty} Stk` const tw = doc.getTextWidth(totals) doc.text(totals, W - m, y, { baseline: 'top', align: 'right' }) putFit(doc, d.companyName, m, y, W - 2 * m - tw - 1.5, 5, 4) return d.lines.length // everything "listed" as totals — no continuation } // Compact labels never paginate: what fits is listed, the rest is summarised. const drawCompact = (doc: any, W: number, H: number, d: LabelData) => { const m = 2 let y = m // company · date doc.setFont('helvetica', 'normal') doc.setFontSize(6) const dw = doc.getTextWidth(d.date) doc.text(d.date, W - m, y, { baseline: 'top', align: 'right' }) doc.setFont('helvetica', 'bold') putFit(doc, d.companyName.toUpperCase(), m, y, W - 2 * m - dw - 2, 6, 4.5) y += 6 * PT_MM + 0.8 doc.setDrawColor(0); doc.setLineWidth(0.25); doc.line(m, y, W - m, y); y += 0.8 // doc no y += putFit(doc, `${d.title} ${d.documentNo}`, m, y, W - 2 * m, 12, 8) + 0.8 // barcode const bcH = H >= 32 ? 9 : 7.5 drawCode128(doc, d.documentNo, m, y, W - 2 * m, bcH, { quietMm: 2, maxModuleMm: 0.5 }) y += bcH + 0.8 // partner (only when it leaves room for the articles) + article rows const rowH = 6 * PT_MM + 0.5 const limitY = H - m if (d.partnerName && d.lines.length <= 2 && limitY - y > rowH * (d.lines.length + 1)) { doc.setFont('helvetica', 'italic') y += putFit(doc, `${d.partnerName}${d.reason ? ' · ' + d.reason : ''}`, m, y, W - 2 * m, 6, 5) + 0.5 } doc.setFont('helvetica', 'normal') let shown = 0 while (shown < d.lines.length && y + rowH <= limitY) { const remaining = d.lines.length - shown // the last row that fits while more remain → summary line instead if (remaining > 1 && y + 2 * rowH > limitY) { doc.setFont('helvetica', 'italic') putFit(doc, `+ ${remaining} weitere Pos. · ${d.totalQty} Stk gesamt`, m, y, W - 2 * m, 6, 5) break } const l = d.lines[shown] doc.setFont('helvetica', 'bold') doc.setFontSize(6) doc.text(`${l.qty}×`, m + 4, y, { baseline: 'top', align: 'right' }) doc.setFont('helvetica', 'normal') putFit(doc, lineLabel(l), m + 5, y, W - 2 * m - 5, 6, 5) y += rowH shown++ } return d.lines.length } const drawStandard = (doc: any, W: number, H: number, d: LabelData, start: number, page: number, pages: number) => { const big = H >= 100 const m = big ? 4 : 2.5 let y = 0 // header band: company (white on black) · date const bandH = big ? 9 : 6 doc.setFillColor(20, 20, 20) doc.rect(0, 0, W, bandH, 'F') doc.setTextColor(255, 255, 255) doc.setFont('helvetica', 'normal') const dateFs = big ? 10 : 8 doc.setFontSize(dateFs) const dw = doc.getTextWidth(d.date) doc.text(d.date, W - m, (bandH - dateFs * PT_MM) / 2, { baseline: 'top', align: 'right' }) doc.setFont('helvetica', 'bold') const cfs = fitTextLine(doc, d.companyName.toUpperCase(), W - 2 * m - dw - 3, big ? 16 : 11, 6) doc.setFontSize(cfs.fontSize) doc.text(cfs.text, m, (bandH - cfs.fontSize * PT_MM) / 2, { baseline: 'top' }) doc.setTextColor(0, 0, 0) y = bandH + (big ? 2.5 : 1.5) // title + document number — same row, title left, doc no right of it, top-aligned const titleFs = big ? 11 : 8 doc.setFont('helvetica', 'bold') doc.setFontSize(titleFs) doc.text(d.title, m, y, { baseline: 'top' }) const titleW = doc.getTextWidth(d.title) const titleGap = big ? 3 : 2 const docNoH = putFit(doc, d.documentNo, m + titleW + titleGap, y - 1, W - 2 * m - titleW - titleGap, big ? 26 : 15, 10) y += Math.max(titleFs * PT_MM, docNoH) + (big ? 2 : 1) // barcode const bcH = big ? 18 : (H >= 60 ? 12 : 10) drawCode128(doc, d.documentNo, m, y, W - 2 * m, bcH, { quietMm: 3, maxModuleMm: big ? 0.6 : 0.5 }) y += bcH + (big ? 2 : 1.2) // partner · reason · locator const infoFs = big ? 9 : 6.5 doc.setFont('helvetica', 'normal') const info: string[] = [] if (d.partnerName) info.push(d.partnerName) if (d.reason) info.push(d.reason) if (d.locatorCode) info.push('Lagerplatz ' + d.locatorCode) if (info.length) y += putFit(doc, info.join(' · '), m, y, W - 2 * m, infoFs, 5) + (big ? 1.5 : 0.8) // article table doc.setDrawColor(0); doc.setLineWidth(big ? 0.35 : 0.25); doc.line(m, y, W - m, y); y += big ? 1.2 : 0.7 const rowFs = big ? 9 : (H >= 60 ? 7 : 6.5) const rowH = rowFs * PT_MM + (big ? 1.6 : 0.8) const footerH = rowFs * PT_MM + (big ? 1.5 : 1) const qtyW = big ? 11 : 7 const valW = W >= 70 ? (big ? 24 : 16) : 0 // header row doc.setFont('helvetica', 'bold'); doc.setFontSize(rowFs - 1) doc.text('Stk', m + qtyW - 1, y, { baseline: 'top', align: 'right' }) doc.text('Artikel', m + qtyW + 1, y, { baseline: 'top' }) if (valW) doc.text('Art.-Nr.', W - m, y, { baseline: 'top', align: 'right' }) y += rowH * 0.9 doc.setLineWidth(0.15); doc.line(m, y - 0.4, W - m, y - 0.4) let shown = start const limitY = H - m - footerH while (shown < d.lines.length && y + rowH <= limitY) { const l = d.lines[shown] doc.setFont('helvetica', 'bold'); doc.setFontSize(rowFs) doc.text(String(l.qty), m + qtyW - 1, y, { baseline: 'top', align: 'right' }) doc.setFont('helvetica', 'normal') let nameW = W - 2 * m - qtyW - 1 if (valW && l.value) { doc.setFontSize(rowFs - 0.5) const vf = fitTextLine(doc, l.value, valW, rowFs - 0.5, 5) doc.setFontSize(vf.fontSize) doc.text(vf.text, W - m, y, { baseline: 'top', align: 'right' }) nameW -= valW + 1.5 } putFit(doc, valW ? l.name : lineLabel(l), m + qtyW + 1, y, nameW, rowFs, Math.max(5, rowFs - 1.5)) y += rowH shown++ } // footer: totals · page doc.setLineWidth(0.25); doc.line(m, H - m - footerH, W - m, H - m - footerH) doc.setFont('helvetica', 'normal'); doc.setFontSize(rowFs - 1) const fy = H - m - footerH + (big ? 1 : 0.6) doc.text(`${d.lines.length} Pos. · ${d.totalQty} Stk`, m, fy, { baseline: 'top' }) if (pages > 1 || shown < d.lines.length) { doc.text(`Seite ${page}/${pages}`, W - m, fy, { baseline: 'top', align: 'right' }) } else if (d.kind === 'rma') { doc.text('RMA', W - m, fy, { baseline: 'top', align: 'right' }) } return shown } // Two-pass render: first count how many lines each page holds (dry run on a scratch // doc of the same size), then draw with the real page count. const renderLabel = (format: [number, number], d: LabelData) => { const orientation: 'l' | 'p' = format[0] >= format[1] ? 'l' : 'p' const make = () => new jsPDF({ orientation, unit: 'mm', format, putOnlyUsedFonts: true, compress: true }) const [W, H] = format const layout = H < 24 ? 'tiny' : (H < 40 ? 'compact' : 'standard') const draw = (doc: any, start: number, page: number, pages: number) => layout === 'tiny' ? drawTiny(doc, W, H, d) : layout === 'compact' ? drawCompact(doc, W, H, d) : drawStandard(doc, W, H, d, start, page, pages) // pass 1: paginate const breaks: number[] = [] { const scratch = make() let start = 0 let guard = 0 do { const shown = draw(scratch, start, breaks.length + 1, 99) if (shown <= start && start < d.lines.length) { breaks.push(d.lines.length); break } // nothing fits → stop breaks.push(shown) start = shown if (start < d.lines.length) scratch.addPage(format, orientation) } while (start < d.lines.length && ++guard < 50) } const pages = Math.max(1, breaks.length) // pass 2: draw const doc = make() let start = 0 for (let p = 0; p < pages; p++) { if (p > 0) doc.addPage(format, orientation) draw(doc, start, p + 1, pages) start = breaks[p] } return { doc, pages, orientation } } // ---- handler --------------------------------------------------------------- export default defineEventHandler(async (event) => { const body = await readBody(event) const kind: 'rma' | 'customer-return' = body?.kind === 'customer-return' ? 'customer-return' : 'rma' const documentNo = s(body?.documentNo) if (!documentNo) { setResponseStatus(event, 400) return { ok: false, status: 400, message: 'documentNo is required' } } const downloadOnly = body?.downloadOnly === true // An explicit, safe `queue` (the session-selected CUST_Printer.CupsName) // takes priority — otherwise LABEL_PRINTERS[labelPrinterId] always won the // `||` below since labelPrinterId defaulted to 'b' (truthy) even when // absent, so body.queue was never actually consulted. const safeQueue = SAFE_QUEUE.test(body?.queue || '') ? String(body.queue) : '' const labelPrinterId = String(body?.labelPrinterId || (safeQueue ? '' : 'b')) const queue = safeQueue || LABEL_PRINTERS[labelPrinterId] || DEFAULT_QUEUE // Company name — authoritative from ad_org (fail-soft to what the client knows) let companyName = s(body?.orgName) const orgId = Number(body?.organizationId) if (orgId > 0) { try { const token = await getTokenHelper(event) const org: any = await fetchHelper(event, `models/ad_org/${orgId}?$select=companyname,Name`, 'GET', token, null) companyName = s(org?.companyname || org?.CompanyName || org?.Name) || companyName } catch (e) { console.warn('[print/return-label] ad_org lookup failed, using client orgName:', (e as any)?.message || e) } } const rawLines: any[] = Array.isArray(body?.lines) ? body.lines : [] const lines: LabelLine[] = rawLines .map((l) => ({ name: s(l?.name), value: s(l?.value || l?.sku), qty: Number(l?.qty) || 0, locatorCode: s(l?.locatorCode) })) .filter((l) => l.qty > 0 && (l.name || l.value)) const locatorCodes = [...new Set(lines.map((l) => l.locatorCode).filter(Boolean))] const data: LabelData = { kind, documentNo, title: kind === 'rma' ? 'RETOURE' : 'KUNDENRÜCKSENDUNG', companyName: companyName || 'LogShip', partnerName: s(body?.partnerName), reason: s(body?.reason), locatorCode: s(body?.locatorCode) || (locatorCodes.length === 1 ? locatorCodes[0] : ''), date: berlinNow(), lines, totalQty: lines.reduce((sum, l) => sum + l.qty, 0) } // Label size: registry → CUPS → legacy (or an explicit preview override) let media: ResolvedMedia | null = null if (downloadOnly && Number(body?.mediaOverride?.w) > 0 && Number(body?.mediaOverride?.h) > 0) { media = { w: Number(body.mediaOverride.w), h: Number(body.mediaOverride.h), source: 'cups' } } else { try { media = await resolveLabelMedia(event, queue) } catch { media = null } } const format: [number, number] = media ? [media.w, media.h] : (LEGACY_FORMAT[labelPrinterId] || [74, 52]) let rendered try { rendered = renderLabel(format, data) } catch (err: any) { console.error('[print/return-label] render failed:', err) setResponseStatus(event, 500) return { ok: false, status: 500, message: 'Etikett konnte nicht erzeugt werden: ' + (err?.message || err) } } const content = rendered.doc.output('dataurlstring')?.replace('data:application/pdf;filename=generated.pdf;base64,', '') const result: any = { ok: true, status: 200, printed: false, printer: queue, labelPrinterId, media, format, pages: rendered.pages, lines: lines.length, totalQty: data.totalQty, companyName: data.companyName, content } if (downloadOnly) return result // Print (same mechanics as /api/print/product) const options = ['-o fit-to-page'] if (media?.source === 'registry') options.push(mediaOption(media)) const dir = '/root/storage/return-labels' const safeDoc = documentNo.replace(/[^A-Za-z0-9_-]/g, '') || 'return' const fileName = `return-label-${kind}-${safeDoc}-${labelPrinterId}-${Date.now()}.pdf` try { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) await writeFile(`${dir}/${fileName}`, Buffer.from(content, 'base64')) const exec = promisify(child_process.exec) const { stdout, stderr } = await exec(`lp -d ${queue} ${options.join(' ')} ${dir}/${fileName}`) result.stdout = stdout || '' result.stderr = stderr || '' result.printed = true result.options = options.join(' ') } catch (err: any) { result.ok = false result.status = 500 result.message = 'Druck fehlgeschlagen: ' + (err?.stderr || err?.message || err) setResponseStatus(event, 500) } delete result.content return result })