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' // --------------------------------------------------------------------------- // Product barcode labels — POST /api/print/product // // Body: { pathName, fileName, labelEntries: [[labelPrinterId(a..f), [entry…]]], // customSettings?, downloadOnly? } // entry: { product, productSKU, productValue, productUPC, organizationId, // qtyEntered, labelBarcode (PNG dataURL, fallback only), labelPrinterId } // // Page size per printer group = the REAL label size, resolved by // `resolveLabelMedia()` (app printer registry → CUPS queue default), so // `lp -o fit-to-page` is a 1:1 mapping. Before, every page was A8 (74×52) and // CUPS shrank it by height onto e.g. the 35×17 mm labels of labelprinter-2 — // a third of the label width stayed blank and the barcode was ~20 mm wide. // // Two size-driven layouts (barcode = vector CODE128, see labelBarcode.ts): // compact (h < 40 mm) name / SKU + org / barcode / cleartext value // table (h ≥ 40 mm) wrapped name / SKU · Art.-Nr. + EAN · org + date / barcode // `customSettings` (Shopify stock page) keeps the legacy fixed-coordinate // layouts on the legacy page formats — their numbers are tuned to A8. // --------------------------------------------------------------------------- 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 page formats when no media can be resolved (e.g. dev without CUPS). const LEGACY_FORMAT: Record = { b: [35, 17], f: [54, 52] } const COMPACT_MAX_HEIGHT = 40 const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)) const PT_MM = 0.3528 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(',', '') const parseCustomFormat = (format: any): any => { if (!format) return null const str = String(format) if (/^\d+,\d+$/.test(str)) { const [w, h] = str.split(',').map(Number) return [w, h] } return str } const pageFormatFor = (labelPrinterId: string, media: ResolvedMedia | null, customSettings: any): any => { const custom = parseCustomFormat(customSettings?.format) if (custom) return custom if (media) return [media.w, media.h] return LEGACY_FORMAT[labelPrinterId] ?? 'a8' } const orientationFor = (format: any): 'l' | 'p' => { if (Array.isArray(format)) return format[0] >= format[1] ? 'l' : 'p' return 'l' } const orgShort = (entry: any, orgNames: Record) => orgNames[entry.organizationId] || (entry.organizationId ? String(entry.organizationId).slice(-2) : '') // Vector barcode with the client PNG as last-resort fallback. const drawBarcode = (doc: any, entry: any, x: number, y: number, w: number, h: number, maxModuleMm = 0.5) => { const code = String(entry.productValue ?? '').trim() || '0000' try { drawCode128(doc, code, x, y, w, h, { quietMm: 2.5, maxModuleMm }) } catch (e) { if (entry.labelBarcode) { doc.addImage(entry.labelBarcode, 'image/png', x + 2.5, y, w - 5, h) } } } // --------------------------------------------------------------------------- // Compact layout — small labels (35×17 on labelprinter-2, 54×29 on -6). // Geometry scales with the label height (s = 1 at 17 mm). // ┌──────────────────────────────┬─┐ // name (bold) │ Produktname Zeile 1 │M│ // name line 2 │ Produktname Zeile 2 … │u│ (only if the name wraps) // sku │ SKU: ABC-123 │s│ (skipped if SKU = Value / empty) // barcode (vector) │ ▌▌ ▌▌▌ ▌ ▌▌ ▌▌▌ ▌ ▌▌ ▌▌▌ ▌ │t│ full width, ≥2.5 mm quiet zones // value (bold) │ 1003278 │…│ human-readable barcode content // └──────────────────────────────┴─┘ // right edge: the org's company name, rotated 90° (bottom → top) // --------------------------------------------------------------------------- const drawCompactProductLabel = (doc: any, entry: any, W: number, H: number, orgNames: Record) => { const s = clamp(H / 17, 1, 1.5) const mx = clamp(W * 0.03, 1.0, 3) // left margin const mr = 0.5 * s // right margin (company name sits at the edge) const my = 0.6 * s const name = String(entry.product ?? '').replace('FOREVER ', '') const value = String(entry.productValue ?? '').trim() const sku = String(entry.productSKU ?? '').trim() const showSku = !!sku && sku !== value const orgName = String(orgNames[entry.organizationId] ?? '').trim() // Right edge — company name rotated 90° (reads bottom → top), vertically centred. let cw = W - mx - mr if (orgName) { doc.setFont('helvetica', 'normal') const orgFit = fitTextLine(doc, orgName, H - 2 * my, 5 * s, 4 * s) const orgCap = orgFit.fontSize * 0.72 * PT_MM const textW = doc.getTextWidth(orgFit.text) doc.text(orgFit.text, W - mr, H / 2 + textW / 2, { angle: 90 }) cw = W - mx - (mr + orgCap + 0.7 * s) } // Rows 1–2 — product name, up to two lines const nameFs = 6.2 * s const nameLh = nameFs * PT_MM * 1.08 doc.setFont('helvetica', 'bold') doc.setFontSize(nameFs) const lines: string[] = doc.splitTextToSize(name, cw) let y = my + nameFs * PT_MM * 0.75 doc.text(lines[0] || '', mx, y) if (lines.length > 1) { y += nameLh doc.text(fitTextLine(doc, lines.slice(1).join(' '), cw, nameFs, nameFs).text, mx, y) } // Row 3 — SKU (optional) if (showSku) { doc.setFont('helvetica', 'normal') const skuFs = 5 * s y += skuFs * PT_MM * 1.15 doc.text(fitTextLine(doc, 'SKU: ' + sku, cw, skuFs, 4.5 * s).text, mx, y) } // Row 5 — human-readable value; Row 4 — barcode fills what is left (≤ 30 mm) doc.setFont('helvetica', 'bold') const valueFit = fitTextLine(doc, value || '0000', cw, 6 * s, 5 * s) const valueCapMm = valueFit.fontSize * 0.72 * PT_MM const barcodeTop = y + 1.2 * s // clear gap under the text rows let valueBaseline = H - 0.5 * s let barcodeH = valueBaseline - valueCapMm - 0.35 * s - barcodeTop if (barcodeH > 30) { barcodeH = 30 valueBaseline = barcodeTop + barcodeH + 0.35 * s + valueCapMm } doc.text(valueFit.text, mx + cw / 2, valueBaseline, { align: 'center' }) drawBarcode(doc, entry, mx, barcodeTop, cw, barcodeH) } // --------------------------------------------------------------------------- // Table layout — larger labels (56×75 on labelprinter-3, 102×152 on -1/-4/-5). // Adaptive version of the former c/d/e/f fixed layouts (reference: A8 74×52 → // 12 pt name, 9 pt table with 4 mm rows, 70×20 mm barcode). // --------------------------------------------------------------------------- const drawTableProductLabel = (doc: any, entry: any, W: number, H: number, orgNames: Record) => { const sw = clamp(W / 74, 0.7, 1.6) const m = clamp(W * 0.02, 1, 2.5) const cw = W - 2 * m const name = String(entry.product ?? '').replace('FOREVER ', '') // Name — up to 3 wrapped lines const nameFs = clamp(12 * sw, 8, 16) doc.setFont('helvetica', 'bold') doc.setFontSize(nameFs) const nameLines: string[] = doc.splitTextToSize(name, cw).slice(0, 3) if (nameLines.length === 3) nameLines[2] = fitTextLine(doc, nameLines[2], cw, nameFs, nameFs).text const lineH = nameFs * PT_MM * 1.15 let y = m + nameFs * PT_MM * 0.85 for (const line of nameLines) { doc.text(line, m, y); y += lineH } // Table — 3 rows: SKU | Art.-Nr. + EAN | org + created // (start below the last name line's descenders) const tableTop = y - lineH + nameFs * PT_MM * 0.3 + 0.8 const rowH = clamp(4 * sw, 3.2, 6) const tableFs = clamp(9 * sw, 6.5, 12) const pad = 1 const textY = (row: number) => tableTop + row * rowH + rowH * 0.72 const leftW = cw * 0.45 const narrow = W < 60 doc.setFont('helvetica', 'normal') doc.setFontSize(tableFs) doc.setDrawColor(0, 0, 0) doc.setLineWidth(0.2) doc.rect(m, tableTop, cw, rowH) doc.text(fitTextLine(doc, 'SKU: ' + (entry.productSKU ?? ''), cw - 2 * pad, tableFs, tableFs * 0.8).text, m + pad, textY(0)) doc.rect(m, tableTop + rowH, leftW, rowH) doc.setFontSize(tableFs) doc.text(fitTextLine(doc, (narrow ? 'Art: ' : 'Artikelnr.: ') + (entry.productValue ?? ''), leftW - 2 * pad, tableFs, tableFs * 0.8).text, m + pad, textY(1)) doc.rect(m + leftW, tableTop + rowH, cw - leftW, rowH) doc.setFontSize(tableFs) doc.text(fitTextLine(doc, 'EAN: ' + (entry.productUPC ?? ''), cw - leftW - 2 * pad, tableFs, tableFs * 0.8).text, m + leftW + pad, textY(1)) doc.rect(m, tableTop + 2 * rowH, leftW, rowH) doc.setFontSize(tableFs) doc.text(fitTextLine(doc, orgShort(entry, orgNames), leftW - 2 * pad, tableFs, tableFs * 0.8).text, m + pad, textY(2)) doc.rect(m + leftW, tableTop + 2 * rowH, cw - leftW, rowH) doc.setFontSize(tableFs) doc.text(fitTextLine(doc, 'Erst.: ' + berlinNow(), cw - leftW - 2 * pad, tableFs, tableFs * 0.8).text, m + leftW + pad, textY(2)) // Barcode — directly under the table, height ≤ 30 mm; wider modules are // allowed on big labels (4×6 in) so the code doesn't look lost. const areaTop = tableTop + 3 * rowH + Math.min(3, Math.max(1.5, H * 0.03)) const areaH = H - m - areaTop const barcodeH = Math.min(areaH, 30) const maxModuleMm = clamp(W / 120, 0.5, 0.8) if (barcodeH > 3) drawBarcode(doc, entry, m, areaTop, cw, barcodeH, maxModuleMm) } // --------------------------------------------------------------------------- // Legacy fixed layouts — only used when the caller passes `customSettings` // (Shopify stock page); coordinates are tuned to the A8 / 54×52 page formats. // --------------------------------------------------------------------------- const drawLegacyLabel = (doc: any, entry: any, orgNames: Record, s: any) => { if(entry.labelPrinterId === 'b') { doc.setFontSize(s?.productFontSize ?? 18) doc.text(String(entry.product.replace("FOREVER ", "")), s?.productX ?? 0, s?.productY ?? 6) doc.setFontSize(s?.skuFontSize ?? 15) doc.text(String( 'SKU:'+ entry.productSKU+ ' - '+ entry.organizationId?.toString().slice(-2)), s?.skuX ?? 0, s?.skuY ?? 13) doc.addImage(entry.labelBarcode, 'image/png', s?.barcodeX ?? 1, s?.barcodeY ?? 16, s?.barcodeW ?? 74, s?.barcodeH ?? 32) } else if(entry.labelPrinterId === 'c') { doc.setFontSize(s?.productFontSize ?? 12) const productText = String(entry.product.replace("FOREVER ", "")) const splitProduct = doc.splitTextToSize(productText, s?.productW ?? 70) doc.text(splitProduct, s?.productX ?? 0, s?.productY ?? 5) const productLines = Math.min(splitProduct.length, 3) const tableStart = (s?.productY ?? 5) + (productLines * 5) doc.setFontSize(s?.tableFontSize ?? 9) doc.rect(1, tableStart, 71, 4) doc.text(String('SKU: ' + entry.productSKU), 2, tableStart + 3) doc.rect(1, tableStart + 4, 32, 4) doc.text(String('Artikelnr.:' + entry.productValue), 2, tableStart + 7) doc.rect(33, tableStart + 4, 39, 4) doc.text(String('EAN: ' + entry.productUPC), 35, tableStart + 7) doc.rect(1, tableStart + 8, 32, 4) doc.text(String(orgShort(entry, orgNames)), 2, tableStart + 11) doc.rect(33, tableStart + 8, 39, 4) doc.text(String(berlinNow()), 35, tableStart + 11) doc.setFontSize(12) doc.addImage(entry.labelBarcode, 'image/png', s?.barcodeX ?? 2, tableStart + 13, s?.barcodeW ?? 70, s?.barcodeH ?? 20) } else if(entry.labelPrinterId === 'd' || entry.labelPrinterId === 'e') { doc.setFontSize(s?.productFontSize ?? 12) const productTextD = String(entry.product.replace("FOREVER ", "")) const splitProductD = doc.splitTextToSize(productTextD, s?.productW ?? 70) doc.text(splitProductD, s?.productX ?? 0, s?.productY ?? 5) doc.setFontSize(s?.tableFontSize ?? 9) const tableStartD = s?.tableStartY ?? 19 doc.rect(1, tableStartD, 71, 4) doc.text(String('SKU: ' + entry.productSKU), 2, tableStartD + 3) doc.rect(1, tableStartD + 4, 32, 4) doc.text(String('Artikelnr.:' + entry.productValue), 2, tableStartD + 7) doc.rect(33, tableStartD + 4, 39, 4) doc.text(String('EAN: ' + entry.productUPC), 35, tableStartD + 7) doc.rect(1, tableStartD + 8, 32, 4) doc.text(String(orgShort(entry, orgNames)), 2, tableStartD + 11) doc.rect(33, tableStartD + 8, 39, 4) doc.text(String((entry.labelPrinterId === 'e' ? 'Erst.:' : '') + berlinNow()), 35, tableStartD + 11) doc.setFontSize(12) doc.addImage(entry.labelBarcode, 'image/png', s?.barcodeX ?? 2, s?.barcodeY ?? 32, s?.barcodeW ?? 70, s?.barcodeH ?? 20) } else if(entry.labelPrinterId === 'f') { doc.setFontSize(s?.productFontSize ?? 12) const productTextF = String(entry.product.replace("FOREVER ", "")) const splitProductF = doc.splitTextToSize(productTextF, s?.productW ?? 52) doc.text(splitProductF, s?.productX ?? 0, s?.productY ?? 6) const productLinesF = Math.min(splitProductF.length, 3) const tableStartF = (s?.productY ?? 6) + (productLinesF * 5) doc.setFontSize(s?.tableFontSize ?? 9) doc.rect(1, tableStartF, 51, 4) doc.text(String('SKU: ' + entry.productSKU), 2, tableStartF + 3) doc.rect(1, tableStartF + 4, 25, 4) doc.text(String('Art:' + entry.productValue), 2, tableStartF + 7) doc.rect(26, tableStartF + 4, 26, 4) doc.text(String('EAN: ' + entry.productUPC), 27, tableStartF + 7) doc.rect(1, tableStartF + 8, 25, 4) doc.text(String(orgShort(entry, orgNames)), 2, tableStartF + 11) doc.rect(26, tableStartF + 8, 26, 4) doc.text(String('Erst:' + berlinNow()), 27, tableStartF + 11) doc.setFontSize(12) doc.addImage(entry.labelBarcode, 'image/png', s?.barcodeX ?? 2, tableStartF + 14, s?.barcodeW ?? 50, s?.barcodeH ?? 18) } else { doc.setFontSize(18) doc.text(String(entry.product.replace("FOREVER ", "")), 0, 8) doc.setFontSize(14) doc.text(String( 'SKU: '+ entry.productSKU + ' | VAL: ' + entry.productValue+ ' | ORG: '+ entry.organizationId?.toString().slice(-2)), 0, 17) doc.addImage(entry.labelBarcode, 'image/png', 9, 20, 60, 28) } } const drawLabel = (doc: any, entry: any, orgNames: Record, customSettings: any) => { if (customSettings) { drawLegacyLabel(doc, entry, orgNames, customSettings) return } const W = doc.internal.pageSize.getWidth() const H = doc.internal.pageSize.getHeight() if (H < COMPACT_MAX_HEIGHT) { drawCompactProductLabel(doc, entry, W, H, orgNames) } else { drawTableProductLabel(doc, entry, W, H, orgNames) } } const serverPrinting = async (item: any) => { const exec = promisify(child_process.exec) const labelPrinter = LABEL_PRINTERS[item.labelEntry] || DEFAULT_QUEUE // Explicit `-o media=` only when the size comes from the app registry (an // intentional override of the queue default); a CUPS-detected size IS the // queue default already. fit-to-page stays as the safety net. const options = ['-o fit-to-page'] if (item.media?.source === 'registry') options.push(mediaOption(item.media)) // Report the TRUE outcome. `lp` exits non-zero (→ exec throws) when the queue // is unknown/disabled/not-accepting, or when the file can't be written — so a // throw here means the label did NOT print. Previously this error was swallowed // and the caller counted "PDF generated" as "printed", hiding every failure. const result: any = { ok: false, printer: labelPrinter, options: options.join(' '), media: item.media ?? null, stdout: '', stderr: '', error: '' } try { if(!existsSync(item.filePath)) { mkdirSync(item.filePath, { recursive: true }) } await writeFile(`${item.filePath}/${item.fileName}`, Buffer.from(item.fileContent, 'base64')) const { stdout, stderr } = await exec(`lp -d ${labelPrinter} ${options.join(' ')} ${item.filePath}/${item.fileName}`); result.stdout = stdout || '' result.stderr = stderr || '' result.ok = true } catch(err: any) { result.error = err?.stderr || err?.message || err?.data?.message || 'Error when printing via server side' result.stderr = err?.stderr || '' result.ok = false } return result } export default defineEventHandler(async (event) => { let data: any = {} const body = await readBody(event) const pathName = body.pathName const filePath = `/root/storage/${pathName}` const fileName = body.fileName const labelEntries = body.labelEntries const customSettings = body.customSettings || null // When true, generate the PDFs server-side and return them, but do NOT // send them to the physical label printer. Used by the "Download Labels" // button on receipt edit so users can grab the file without reprinting. const downloadOnly = body.downloadOnly === true data['path'] = filePath data['name'] = fileName data['contents'] = [] data['printResults'] = [] // Fetch org company names once for the entire print session const orgNames: Record = {} try { const orgIds = [...new Set(labelEntries.flatMap(([, vals]) => vals.flat().map((e: any) => e.organizationId)).filter(Boolean))] if (orgIds.length > 0) { const token = await getTokenHelper(event) const orgFilter = orgIds.map(id => `AD_Org_ID eq ${id}`).join(' or ') const orgRes: any = await fetchHelper(event, `models/ad_org?$filter=${orgFilter}&$select=AD_Org_ID,companyname,Name`, 'GET', token, null) for (const org of orgRes?.records || []) { orgNames[org.id] = org.companyname || org.Name || '' } } } catch (e) { // fallback: orgNames stays empty, label will use organizationId } for(const [labelEntry, labelValues] of labelEntries) { const queue = LABEL_PRINTERS[labelEntry] || DEFAULT_QUEUE let media: ResolvedMedia | null = null if (!customSettings) { try { media = await resolveLabelMedia(event, queue) } catch { media = null } } const pageFormat = pageFormatFor(labelEntry, media, customSettings) const orientation = orientationFor(pageFormat) const doc = new jsPDF({ orientation, unit: 'mm', format: pageFormat, putOnlyUsedFonts: true, compress: true }) // The constructor already opened page 1 — draw the first label on it and // add a page (same format) for every further label. (The previous // addPage()/setPage(no) dance drew each label on the page created for the // NEXT one, so the first label of a batch landed on a default-format page.) let count = 0 for(let entry of labelValues.flat()) { for(let i = 0; i < Number(entry.qtyEntered || 0); i++) { if (count > 0) doc.addPage(pageFormat, orientation) drawLabel(doc, entry, orgNames, customSettings) count++ } } if (count === 0) continue const fileContent = doc.output('dataurlstring')?.replace('data:application/pdf;filename=generated.pdf;base64,', '') data['contents'].push({ label: labelEntry, printer: queue, media, pages: count, content: fileContent }) if (!downloadOnly) { const resPrint = await serverPrinting({ filePath, fileName: fileName.replace('-LABELS-', `-LABELPRINTER-${labelEntry}-`), fileContent, labelEntry, media }) if(resPrint) { data['print_result'] = resPrint // kept for backward-compat (last group) data['printResults'].push({ labelEntry, ...resPrint }) } } } // Honest overall verdict: did EVERY printer group actually reach the printer? // (Only meaningful when we actually printed — download-only never prints.) if (!downloadOnly) { data['printOk'] = data['printResults'].length > 0 && data['printResults'].every((r: any) => r.ok) } return data })