/** * Shared logic for the FBA-relabel print surfaces on the sales-order edit page * (label print modal + DIN-A4 report modal). * * Rows come from the order's lines: every active product line with its * printLabelCode (the FNSKU / alternative barcode Amazon requires), SKU, name * and qty. Barcodes are generated CLIENT-side as PNG data URLs (bwip-js, * CODE128) and embedded by the server / pdfmake — the server never generates * barcodes (same split as the receipt label printing). * * SEQUENCE: when the order already has a shipment, the rows follow the * SHIPMENT positions (m_inoutline.Line) — that is the order in which the goods * are physically handled while relabeling. The FNSKU only lives on the ORDER * line, so each shipment line is matched back to its order line to pick it up. * Without a shipment (or if the lookup fails) the order-line sequence stands. */ import { loadPdfMake } from '~/utils/loadPdfMake' export interface RelabelRow { line: string | number sku: string upc: string product: string code: string qty: number orderLineId?: string | number productId?: string | number } export const useRelabelDocs = () => { /** * Order-line ids in shipment sequence: every shipment of the order (oldest * first), each one's lines by m_inoutline.Line. Returns [] when the order has * no shipment yet. */ const fetchShipmentSequence = async (orderId: any) => { const res: any = await $fetch(`/api/orders/${orderId}/shipments`, { headers: useRequestHeaders(['cookie']) }) const shipments = (res?.records || []) // Only outgoing customer shipments — a customer RETURN (C+) booked against // the same order must not reorder the labels. .filter((s: any) => { const type = String(s?.MovementType?.id ?? s?.MovementType ?? '') const status = String(s?.DocStatus?.id ?? s?.DocStatus ?? '') return (!type || type.startsWith('C-')) && !['VO', 'RE'].includes(status) }) // The endpoint sorts newest-first; relabel in the order they were shipped. .reverse() const sequence: any[] = [] for (const shipment of shipments) { const lineRes: any = await $fetch(`/api/inouts/${shipment.id}/inout-line`, { headers: useRequestHeaders(['cookie']) }) const lines = [...(lineRes?.records || [])] .sort((a: any, b: any) => Number(a?.Line || 0) - Number(b?.Line || 0)) for (const line of lines) { sequence.push({ orderLineId: line?.C_OrderLine_ID?.id ?? null, productId: line?.M_Product_ID?.id ?? null }) } } return sequence } const fetchRelabelRows = async (orderId: any): Promise => { const res: any = await $fetch(`/api/orders/${orderId}/order-line`, { headers: useRequestHeaders(['cookie']) }) const orderRows: RelabelRow[] = (res?.records || []) .filter((item: any) => item?.IsActive !== false && item?.M_Product_ID?.id && Number(item?.QtyEntered || 0) > 0) .map((item: any) => ({ line: item?.Line || '', sku: item?.M_Product_ID?.SKU || item?.M_Product_ID?.Value || '', // iDempiere stores UPC and EAN in one field (UPC, labeled "UPC/EAN"); UPC2 = second barcode upc: item?.M_Product_ID?.UPC || item?.M_Product_ID?.UPC2 || '', product: item?.M_Product_ID?.Name || item?.M_Product_ID?.identifier || '', // The FNSKU entered during relabel capture — the whole point of the join below. code: String(item?.printLabelCode ?? item?.PrintLabelCode ?? '').trim(), qty: Number(item?.QtyEntered || 0), orderLineId: item?.id ?? '', productId: item?.M_Product_ID?.id ?? '' })) .sort((a: any, b: any) => Number(a.line) - Number(b.line)) // Fail-soft: no shipment, or the lookup broke → keep the order-line sequence. let sequence: any[] = [] try { sequence = await fetchShipmentSequence(orderId) } catch (e) { console.error('Shipment sequence lookup failed, falling back to order lines:', e) } if (!sequence.length) return orderRows const byOrderLine = new Map(orderRows.map(r => [String(r.orderLineId), r] as [string, RelabelRow])) const used = new Set() const ordered: RelabelRow[] = [] for (const entry of sequence) { // A shipment line carries the order-line FK; fall back to the product when // it doesn't (manually added shipment line). let row = entry.orderLineId ? byOrderLine.get(String(entry.orderLineId)) : undefined if (!row && entry.productId) { row = orderRows.find(r => !used.has(String(r.orderLineId)) && String(r.productId) === String(entry.productId)) } // Skipped here: one order line split over several shipment lines (locators) // keeps ONE label row at its first appearance, and a shipment line with no // order line behind it has no FNSKU, so it can't be labeled anyway. if (!row || used.has(String(row.orderLineId))) continue used.add(String(row.orderLineId)) ordered.push(row) } // Positions no shipment covers are still printable — append them in their // order-line sequence rather than dropping them. for (const row of orderRows) { if (!used.has(String(row.orderLineId))) ordered.push(row) } return ordered } const generateBarcodeDataUrl = async (code: string): Promise => { // bwip-js browser build at high module scale — the same rasteriser the // Label Designer uses (proven readable on the thermal label printers). // The previous jsbarcode default rendered ~2px bars that had to be // UPSCALED onto the label → blurry, unreadable bars on small labels // (e.g. labelprinter-2). A high-res PNG lets the printer DOWNSAMPLE, // which keeps the bar edges crisp. No internal padding — the label // layout itself insets the barcode ≥3 mm on both sides (quiet zone). const mod: any = await import('bwip-js/browser') const bwip = (mod && typeof mod.toCanvas === 'function') ? mod : (mod?.default ?? mod) const canvas = document.createElement('canvas') bwip.toCanvas(canvas, { bcid: 'code128', text: String(code), scale: 6, height: 12, includetext: false }) return canvas.toDataURL('image/png') } /** * DIN-A4 report: which label (barcode + code) goes onto which article (SKU). * Rows must already carry `barcode` (PNG data URL from generateBarcodeDataUrl). */ // Article names are capped at ~5 wrapped lines in the Artikel column // (~14 chars/line at 9pt in the current column width); longer names end // with "…" so a single position can't grow arbitrarily tall. const truncateProductName = (name: any, maxChars = 70) => { const s = String(name || '') if (s.length <= maxChars) return s return s.slice(0, maxChars - 1).trimEnd() + '…' } const buildRelabelReportDoc = async (rows: Array, documentNo: string) => { const pdfMake = await loadPdfMake() const tableBody: any[] = [[ { text: 'Pos.', style: 'th' }, { text: 'SKU', style: 'th' }, { text: 'EAN/UPC', style: 'th' }, { text: 'Artikel', style: 'th' }, { text: 'Etiketten-Code (FBA)', style: 'th' }, { text: 'Menge', style: 'th', alignment: 'right' }, // Empty column for the operator to WRITE DOWN which parcel (Paket) the // position was finally packed into — intentionally blank on print. { text: 'Paket-Nr.', style: 'th', alignment: 'center' } ]] for (const r of rows) { tableBody.push([ { text: String(r.line), margin: [0, 8, 0, 0] }, { text: r.sku, bold: true, margin: [0, 8, 0, 0] }, { text: r.upc || '-', margin: [0, 8, 0, 0] }, { text: truncateProductName(r.product), margin: [0, 8, 0, 0] }, { stack: [ { image: r.barcode, width: 150, height: 34 }, { text: r.code, bold: true, fontSize: 9, margin: [0, 2, 0, 0] } ], margin: [0, 4, 0, 4] }, { text: String(r.qty), alignment: 'right', bold: true, margin: [0, 8, 0, 0] }, { text: '' } ]) } const totalQty = rows.reduce((acc, r) => acc + Number(r.qty || 0), 0) tableBody.push([ { text: '', colSpan: 4 }, {}, {}, {}, { text: 'Etiketten gesamt', bold: true, alignment: 'right', margin: [0, 4, 0, 0] }, { text: String(totalQty), bold: true, alignment: 'right', margin: [0, 4, 0, 0] }, { text: '' } ]) const docDefinition: any = { pageSize: 'A4', pageMargins: [40, 50, 40, 50], // Page numbers (with the order no.) so multi-page printouts can be related // to each other when the sheets get separated. footer: (currentPage: number, pageCount: number) => ({ columns: [ { text: `FBA-Relabel – Auftrag ${documentNo || ''}`, fontSize: 8, color: '#777777' }, { text: `Seite ${currentPage} von ${pageCount}`, fontSize: 8, color: '#777777', alignment: 'right' } ], margin: [40, 15, 40, 0] }), content: [ { text: `FBA-Relabel – Auftrag ${documentNo || ''}`, fontSize: 15, bold: true }, { text: 'Übersicht: welches Etikett auf welchen Artikel (SKU) geklebt wird. Paket-Nr.: hier eintragen, in welches Paket die Position gepackt wurde.', fontSize: 9, color: '#555555', margin: [0, 3, 0, 4] }, // Amazon FBA labeling rules (official "How to label products" guide) { text: 'Wichtig (Amazon-Vorgaben): Original-Barcode (EAN/UPC) vollständig überkleben. Label nicht auf Kanten, Ecken oder Rundungen kleben — ca. 6 mm Abstand zum Verpackungsrand einhalten.', fontSize: 9, bold: true, color: '#b45309', margin: [0, 0, 0, 12] }, { table: { headerRows: 1, // dontBreakRows: a position (SKU/EAN/name/barcode) must never be // split across a page break — the whole row moves to the next page. dontBreakRows: true, widths: [24, 70, 68, '*', 152, 30, 44], body: tableBody }, layout: { hLineColor: () => '#cccccc', vLineColor: () => '#cccccc', hLineWidth: () => 0.5, vLineWidth: () => 0.5 } }, { text: `Erstellt: ${new Date().toLocaleString('de-DE', { timeZone: 'Europe/Berlin' })}`, fontSize: 8, color: '#777777', margin: [0, 10, 0, 0] } ], styles: { th: { bold: true, fontSize: 9, fillColor: '#f0f0f0', margin: [0, 2, 0, 2] } }, defaultStyle: { fontSize: 9 } } return pdfMake.createPdf(docDefinition) } return { fetchRelabelRows, generateBarcodeDataUrl, buildRelabelReportDoc } }